@cosmicdrift/kumiko-server-runtime 0.389.0 → 0.390.1

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.
@@ -1,4 +1,5 @@
1
1
  import { seedAllConfigValues } from "@cosmicdrift/kumiko-bundled-features/config";
2
+ import { runInSystemRowLevelSecurityScope } from "@cosmicdrift/kumiko-framework/internal/row-level-security";
2
3
  // Single boot-seed entry-point. runDevApp + runProdApp both call this
3
4
  // from their post-stack hook, so the wiring lives in exactly one place
4
5
  // — config-seed-boot.integration.ts pins this helper, which means a
@@ -7,12 +8,14 @@ import { seedAllConfigValues } from "@cosmicdrift/kumiko-bundled-features/config
7
8
  // shipping a server that never seeds.
8
9
  // @wrapper-known semantic-alias
9
10
  export async function applyBootSeeds(deps) {
10
- await seedAllConfigValues(deps.registry, deps.db, deps.cipher);
11
- // Loaded lazily: the seeding module pulls the document sanitizer (happy-dom),
12
- // which apps without notification templates must not pay for at boot.
13
- const hasNotificationTemplates = Array.from(deps.registry.getAllNotifications().values()).some((notification) => notification.template !== undefined);
14
- if (hasNotificationTemplates) {
15
- const { seedNotificationTemplates } = await import("@cosmicdrift/kumiko-bundled-features/template-resolver/seeding");
16
- await seedNotificationTemplates(deps.db, deps.registry);
17
- }
11
+ return runInSystemRowLevelSecurityScope("boot seeds write rows for arbitrary tenants", async () => {
12
+ await seedAllConfigValues(deps.registry, deps.db, deps.cipher);
13
+ // Loaded lazily: the seeding module pulls the document sanitizer (happy-dom),
14
+ // which apps without notification templates must not pay for at boot.
15
+ const hasNotificationTemplates = Array.from(deps.registry.getAllNotifications().values()).some((notification) => notification.template !== undefined);
16
+ if (hasNotificationTemplates) {
17
+ const { seedNotificationTemplates } = await import("@cosmicdrift/kumiko-bundled-features/template-resolver/seeding");
18
+ await seedNotificationTemplates(deps.db, deps.registry);
19
+ }
20
+ });
18
21
  }
@@ -27,7 +27,7 @@ export async function startDevJobRunners(opts) {
27
27
  consumerLane: lane,
28
28
  ...logger,
29
29
  });
30
- // Without this, ctx.write/ctx.queryAs inside a dev-run job throw
30
+ // Without this, ctx.dispatch.write/ctx.dispatch.queryAs inside a dev-run job throw
31
31
  // "dispatcher attached — call attachDispatcher() first" on their first
32
32
  // call — the prod entrypoints attach automatically, dev boot must too.
33
33
  jr.attachDispatcher(dispatcherToWriteRef(opts.dispatcher));
@@ -1,7 +1,7 @@
1
1
  import { type SeedAdminOptions } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/seeding";
2
2
  import type { AuthRoutesConfig } from "@cosmicdrift/kumiko-framework/api";
3
3
  import type { KmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
4
- import { type DbConnection } from "@cosmicdrift/kumiko-framework/db";
4
+ import { type DbConnection, type RowLevelSecurityMode } from "@cosmicdrift/kumiko-framework/db";
5
5
  import { type BundledListsOptions, type EffectiveFeaturesResolver, type FeatureDefinition, type Registry, type ValidateBootOptions } from "@cosmicdrift/kumiko-framework/engine";
6
6
  import type { CacheSyncBus } from "@cosmicdrift/kumiko-framework/redis";
7
7
  import type { EnvelopeCipher } from "@cosmicdrift/kumiko-framework/secrets";
@@ -37,7 +37,7 @@ export type SharedAuthRouteSource = SharedAuthFlowFields & Pick<RunProdAppAuthOp
37
37
  export type SharedAuthRoutes = AuthFlowRoutes & Pick<AuthRoutesConfig, "membershipQuery" | "loginHandler" | "loginErrorStatusMap" | "cookieDomain" | "retiredCookieDomains" | "allowedOrigins" | "unsafeSkipOriginCheck" | "postAuthLanding" | "mfaVerifyHandler" | "switchTenantMfaGateHandler" | "mfaPreauthEnableStartHandler" | "mfaPreauthConfirmHandler">;
38
38
  export declare function buildSharedAuthRoutes(effectiveAuth: SharedAuthRouteSource, hasMfa: boolean): SharedAuthRoutes;
39
39
  export declare function waitForKmsHealthy(kms: KmsAdapter, processName: string, deadline: number, clock?: DatabaseWaitClock): Promise<void>;
40
- export declare function openDatabasePools(databaseUrl: string, envSource: Record<string, string | undefined>): {
40
+ export declare function openDatabasePools(databaseUrl: string, envSource: Record<string, string | undefined>, rowLevelSecurity: RowLevelSecurityMode): {
41
41
  readonly db: DbConnection;
42
42
  readonly closeDb: () => Promise<void>;
43
43
  readonly outsideTransactionDb: DbConnection;
@@ -52,7 +52,9 @@ export async function applyStandardBootSeeds(deps) {
52
52
  // error stays fatal. Later boot steps build on the seeds, so this cannot continue in the background.
53
53
  await waitUntilDatabaseReachable(async () => {
54
54
  if (admin) {
55
- await seedAdminGuarded(db, admin);
55
+ // Operator-created account, not a self-registration: under emailVerificationMode "strict"
56
+ // an unverified seed admin could never log in. `admin.emailVerified` still overrides.
57
+ await seedAdminGuarded(db, { emailVerified: true, ...admin });
56
58
  }
57
59
  for (const seed of shortcutSeeds) {
58
60
  await seed(db);
@@ -145,14 +147,16 @@ export async function waitForKmsHealthy(kms, processName, deadline, clock) {
145
147
  throw new Error(`[${processName}] BOOT ABORTED — KMS health check failed (latency ${kmsHealth.latencyMs}ms)`);
146
148
  }
147
149
  }
148
- export function openDatabasePools(databaseUrl, envSource) {
150
+ export function openDatabasePools(databaseUrl, envSource, rowLevelSecurity) {
149
151
  const { db, close: closeDb } = createDbConnection({
150
152
  url: databaseUrl,
153
+ rowLevelSecurity,
151
154
  ...dbConnectionOptionsFromEnv(envSource),
152
155
  ...serverTimeoutOptionsFromEnv(envSource),
153
156
  });
154
157
  const { db: outsideTransactionDb, close: closeOutsideTransactionDb } = createDbConnection({
155
158
  url: databaseUrl,
159
+ rowLevelSecurity,
156
160
  ...outsideTransactionPoolOptionsFromEnv(envSource),
157
161
  ...serverTimeoutOptionsFromEnv(envSource),
158
162
  });
@@ -0,0 +1,27 @@
1
+ import { type DbConnection } from "@cosmicdrift/kumiko-framework/db";
2
+ import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
4
+ import { type DatabaseWaitClock } from "./wait-for-database.ts";
5
+ export declare const STREAM_TENANT_BACKFILL_LOCK_KEY = 1937007206;
6
+ /** Declares that the app has no search backend in any of its processes. */
7
+ export type SearchBackendDeclaration = "none";
8
+ export type StreamBackfillPart = "user" | "tenant";
9
+ export declare function streamBackfillPendingId(part: StreamBackfillPart): string;
10
+ export type StreamBackfillPartOutcome = "feature-missing" | "no-search-adapter" | "completed" | "failed";
11
+ export type StreamTenantBackfillOutcome = {
12
+ readonly user: StreamBackfillPartOutcome;
13
+ readonly tenant: StreamBackfillPartOutcome;
14
+ readonly aggregatesMigrated: number;
15
+ };
16
+ type Log = ReturnType<typeof createFallbackLogger>;
17
+ export declare function runStreamTenantBackfillOnBoot(opts: {
18
+ readonly db: DbConnection;
19
+ readonly registry: Registry;
20
+ readonly extraContext: object;
21
+ readonly processName: string;
22
+ /** `"none"`: the app has no search backend. Without it a process lacking a search adapter defers the work. */
23
+ readonly search?: SearchBackendDeclaration;
24
+ readonly databaseWait?: DatabaseWaitClock;
25
+ readonly log?: Log;
26
+ }): Promise<StreamTenantBackfillOutcome>;
27
+ export {};
@@ -0,0 +1,161 @@
1
+ // Moves legacy tenant, tenant-membership and user streams onto the tenant their
2
+ // aggregate type now addresses (SYSTEM_TENANT_ID or the membership's payload tenant), then
3
+ // repairs the derived state (stale search docs, projection rows, index).
4
+ //
5
+ // There is no completion marker: every boot searches for legacy streams, so streams that
6
+ // older pods write during a rolling deploy are fixed on the next boot. The search only reads
7
+ // events of other tenants than the target (shared/stream-tenant-backfill.ts), which is cheap
8
+ // when nothing is left. Once the scan finds work a part writes a "derived-pending" row (a clean boot writes nothing); while it exists,
9
+ // rebuild and reindex run even if no stream moves, so a crash after the moves cannot leave
10
+ // stale rows behind. Failures are logged and retried on the next boot.
11
+ // A process that has no search adapter although the entity is searchable leaves the work to
12
+ // one that has: moving streams without cleaning the old buckets would orphan search docs.
13
+ // An app that declares `search: "none"` has no search backend in any process, so there are no
14
+ // docs to orphan: the step moves and rebuilds, and the reindex stays out.
15
+ import { backfillTenantStreamTenants } from "@cosmicdrift/kumiko-bundled-features/tenant";
16
+ import { backfillUserStreamTenantsWithDerivedState } from "@cosmicdrift/kumiko-bundled-features/user";
17
+ import { resolveBaseDbAndSearchAdapter } from "@cosmicdrift/kumiko-framework/api";
18
+ import { deleteMany, fetchOne, insertOne, listEventTenantIds, withSessionAdvisoryLock, } from "@cosmicdrift/kumiko-framework/db";
19
+ import { createEsOperationsTable, esOperationsTable } from "@cosmicdrift/kumiko-framework/es-ops";
20
+ import { runInSystemRowLevelSecurityScope } from "@cosmicdrift/kumiko-framework/internal/row-level-security";
21
+ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
22
+ import { waitUntilDatabaseReachable } from "./wait-for-database.js";
23
+ export const STREAM_TENANT_BACKFILL_LOCK_KEY = 0x73_74_62_66; // 'stbf'
24
+ export function streamBackfillPendingId(part) {
25
+ return `stream-tenant-backfill:${part}:derived-pending`;
26
+ }
27
+ export async function runStreamTenantBackfillOnBoot(opts) {
28
+ const { db, registry, processName, search } = opts;
29
+ const log = opts.log ?? createFallbackLogger("stream-tenant-backfill");
30
+ const parts = ["user", "tenant"].filter((part) => registry.features.has(part));
31
+ const { searchAdapter } = resolveBaseDbAndSearchAdapter(opts.extraContext, registry);
32
+ if (search === "none" && searchAdapter) {
33
+ throw new Error(`${processName}: \`search: "none"\` declares an app without a search backend, but extraContext provides a searchAdapter. Remove one of the two.`);
34
+ }
35
+ if (parts.length === 0)
36
+ return outcomeOf(parts, {}, 0);
37
+ return waitUntilDatabaseReachable(() => runInSystemRowLevelSecurityScope("stream tenant backfill moves streams across tenants", async () => {
38
+ await createEsOperationsTable(db);
39
+ return withSessionAdvisoryLock(db, STREAM_TENANT_BACKFILL_LOCK_KEY, async () => {
40
+ const deps = {
41
+ db,
42
+ registry,
43
+ searchAdapter,
44
+ log,
45
+ search,
46
+ scanTenantIds: await listEventTenantIds(db),
47
+ };
48
+ const outcomes = {};
49
+ let migrated = 0;
50
+ for (const part of parts) {
51
+ const result = await runPart(part, deps);
52
+ outcomes[part] = result.outcome;
53
+ migrated += result.aggregatesMigrated;
54
+ }
55
+ return outcomeOf(parts, outcomes, migrated);
56
+ });
57
+ }), { ...opts.databaseWait, processName, target: "database (stream tenant backfill)" });
58
+ }
59
+ function outcomeOf(parts, outcomes, aggregatesMigrated) {
60
+ const of = (part) => parts.includes(part) ? (outcomes[part] ?? "completed") : "feature-missing";
61
+ return { user: of("user"), tenant: of("tenant"), aggregatesMigrated };
62
+ }
63
+ async function operationExists(db, id) {
64
+ return (await fetchOne(db, esOperationsTable, { id })) !== undefined;
65
+ }
66
+ // An app declaring `search: "none"` has no docs to orphan, so only apps that may have a backend defer.
67
+ function isDeferredWithoutSearchAdapter(searchable, deps) {
68
+ return searchable && !deps.searchAdapter && deps.search !== "none";
69
+ }
70
+ async function runPart(part, deps) {
71
+ const { db, registry, log } = deps;
72
+ const entity = part === "user" ? "user" : "tenant";
73
+ const searchable = registry.getSearchableFields(entity).length > 0 ||
74
+ registry.getSearchPayloadExtensions(entity).length > 0;
75
+ const blockedWithoutAdapter = isDeferredWithoutSearchAdapter(searchable, deps);
76
+ const warnBlocked = () => log.warn(`stream tenant backfill (${part}) has work but "${entity}" is searchable and this process has no search adapter; a process with one runs it`);
77
+ const pendingId = streamBackfillPendingId(part);
78
+ const wasPending = await operationExists(db, pendingId);
79
+ if (wasPending && blockedWithoutAdapter) {
80
+ warnBlocked();
81
+ return { outcome: "no-search-adapter", aggregatesMigrated: 0 };
82
+ }
83
+ // A clean boot writes nothing: the pending row appears only once the scan found work.
84
+ let pendingExists = wasPending;
85
+ let skipped = false;
86
+ const onFirstCandidate = async () => {
87
+ if (skipped || blockedWithoutAdapter) {
88
+ if (!skipped)
89
+ warnBlocked();
90
+ skipped = true;
91
+ return "skip";
92
+ }
93
+ if (!pendingExists) {
94
+ await recordPending(db, pendingId);
95
+ pendingExists = true;
96
+ }
97
+ return "proceed";
98
+ };
99
+ // A throw (transient connection error) reaches waitUntilDatabaseReachable; the pending row stays for the retry.
100
+ const run = await runBackfill(part, deps, wasPending ? "always" : "if-moved", onFirstCandidate);
101
+ if (skipped)
102
+ return { outcome: "no-search-adapter", aggregatesMigrated: 0 };
103
+ for (const failure of run.failures) {
104
+ log.error(`stream tenant backfill (${part}) failed; it is retried on the next boot`, {
105
+ aggregateId: failure.aggregateId,
106
+ step: failure.step,
107
+ error: failure.error,
108
+ });
109
+ }
110
+ if (run.failures.length > 0) {
111
+ // Moves that keep failing (a broken aggregate) must not force rebuild and reindex on every
112
+ // boot: the pending row only guards the derived steps, which succeeded here.
113
+ if (pendingExists && !run.failures.some(isDerivedStateFailure)) {
114
+ await clearPending(db, pendingId);
115
+ }
116
+ return { outcome: "failed", aggregatesMigrated: run.aggregatesMigrated };
117
+ }
118
+ if (pendingExists)
119
+ await clearPending(db, pendingId);
120
+ if (run.aggregatesMigrated > 0) {
121
+ log.info(`stream tenant backfill (${part}) moved streams`, {
122
+ aggregatesMigrated: run.aggregatesMigrated,
123
+ });
124
+ }
125
+ return { outcome: "completed", aggregatesMigrated: run.aggregatesMigrated };
126
+ }
127
+ // Step names of rebuildAndReindex in bundled-features/shared/stream-tenant-derived-state.ts.
128
+ function isDerivedStateFailure(failure) {
129
+ return failure.step === "rebuild" || failure.step === "reindex";
130
+ }
131
+ async function clearPending(db, pendingId) {
132
+ await deleteMany(db, esOperationsTable, { id: pendingId });
133
+ }
134
+ async function recordPending(db, id) {
135
+ await insertOne(db, esOperationsTable, {
136
+ id,
137
+ operationType: "stream-migration",
138
+ durationMs: 0,
139
+ appliedBy: "boot",
140
+ });
141
+ }
142
+ async function runBackfill(part, deps, derived, onFirstCandidate) {
143
+ const { db, registry, searchAdapter, scanTenantIds } = deps;
144
+ const common = {
145
+ db,
146
+ registry,
147
+ derived,
148
+ onFirstCandidate,
149
+ ...(searchAdapter && { searchAdapter }),
150
+ scanTenantIds,
151
+ };
152
+ if (part === "user") {
153
+ const result = await backfillUserStreamTenantsWithDerivedState(common);
154
+ return { failures: result.failures, aggregatesMigrated: result.streams.aggregatesMigrated };
155
+ }
156
+ const result = await backfillTenantStreamTenants(common);
157
+ return {
158
+ failures: result.failures,
159
+ aggregatesMigrated: result.tenants.aggregatesMigrated + result.memberships.aggregatesMigrated,
160
+ };
161
+ }
@@ -0,0 +1,3 @@
1
+ import type { RowLevelSecurityMode } from "@cosmicdrift/kumiko-framework/db";
2
+ import type { Logger } from "@cosmicdrift/kumiko-framework/logging";
3
+ export declare function warnIfRowLevelSecurityOff(mode: RowLevelSecurityMode, log: Pick<Logger, "warn">): void;
@@ -0,0 +1,6 @@
1
+ export function warnIfRowLevelSecurityOff(mode, log) {
2
+ // skip: the default mode needs no warning
3
+ if (mode !== "off")
4
+ return;
5
+ log.warn("rowLevelSecurity is off: tenant isolation relies on the application layer only; Postgres row level security is not enforced");
6
+ }
@@ -25,8 +25,9 @@ export type BootedWorkerProcess = {
25
25
  readonly registry: Registry;
26
26
  readonly features: readonly FeatureDefinition[];
27
27
  readonly envSource: Record<string, string | undefined>;
28
+ readonly extraContext: Record<string, unknown>;
28
29
  readonly entrypoint: WorkerEntrypoint;
29
- /** ctx.secrets as wired into the entrypoint (auto-wired when the secrets
30
+ /** ctx.services.secrets as wired into the entrypoint (auto-wired when the secrets
30
31
  * feature is mounted and a master key exists, or from `extraContext`). */
31
32
  readonly secrets?: SecretsContext;
32
33
  /** Envelope cipher for `encrypted` config keys, when a master key is wired. */
@@ -5,9 +5,11 @@
5
5
  // share every step that decides ciphers, blind indexes and config encryption.
6
6
  import { loadJwtSecretOrKeyring } from "@cosmicdrift/kumiko-framework/api";
7
7
  import { configureBlindIndexKey, configurePiiSubjectKms, resolvePlatformKeks, } from "@cosmicdrift/kumiko-framework/crypto";
8
- import { configureEntityFieldEncryption, } from "@cosmicdrift/kumiko-framework/db";
8
+ import { assertRowLevelSecurityBootState, configureEntityFieldEncryption, knownTablesOf, } from "@cosmicdrift/kumiko-framework/db";
9
9
  import { createRegistry, validateBoot, } from "@cosmicdrift/kumiko-framework/engine";
10
10
  import { createWorkerEntrypoint, } from "@cosmicdrift/kumiko-framework/entrypoint";
11
+ import { DEFAULT_ROW_LEVEL_SECURITY_MODE } from "@cosmicdrift/kumiko-framework/internal/row-level-security";
12
+ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
11
13
  import { assertKumikoSchemaCurrent, SchemaDriftError, } from "@cosmicdrift/kumiko-framework/migrations";
12
14
  import { createEntityCache, createEventDedup, createIdempotencyGuard, } from "@cosmicdrift/kumiko-framework/pipeline";
13
15
  import { createRedisCacheSyncBus, redisClientOptionsFromEnv, } from "@cosmicdrift/kumiko-framework/redis";
@@ -23,6 +25,7 @@ import { jobRunLoggerCallbacks } from "./job-run-logger.js";
23
25
  import { INSTANCE_ID_ENV } from "./record-app-started-on-boot.js";
24
26
  import { buildEffectiveFeaturesResolver, openDatabasePools, waitForKmsHealthy, } from "./shared-boot-steps.js";
25
27
  import { WAIT_FOR_DATABASE_DEADLINE_MS, waitForDatabase } from "./wait-for-database.js";
28
+ import { warnIfRowLevelSecurityOff } from "./warn-row-level-security-off.js";
26
29
  import { assertWorkerMetricsOptions } from "./worker-metrics-server.js";
27
30
  function isSecretsContext(value) {
28
31
  if (typeof value !== "object" || value === null)
@@ -62,6 +65,7 @@ export async function bootWorkerProcess(options, envSource, profile) {
62
65
  const databaseUrl = requireEnv("DATABASE_URL", envSource, processName);
63
66
  const redisUrl = requireEnv("REDIS_URL", envSource, processName);
64
67
  const jwtSecretOrKeyring = loadJwtSecretOrKeyring(envSource);
68
+ const rowLevelSecurity = options.rowLevelSecurity ?? DEFAULT_ROW_LEVEL_SECURITY_MODE;
65
69
  // MUST be composed with the same `includeBundled` as the API process.
66
70
  const includeBundled = !!options.includeBundled || profile.authOptions !== undefined;
67
71
  const features = composeFeatures(options.features, {
@@ -97,7 +101,7 @@ export async function bootWorkerProcess(options, envSource, profile) {
97
101
  if (options.kms) {
98
102
  await waitForKmsHealthy(options.kms, processName, databaseWaitDeadline, options.databaseWait);
99
103
  }
100
- const { db, closeDb, outsideTransactionDb, closeOutsideTransactionDb } = openDatabasePools(databaseUrl, envSource);
104
+ const { db, closeDb, outsideTransactionDb, closeOutsideTransactionDb } = openDatabasePools(databaseUrl, envSource, rowLevelSecurity);
101
105
  const redis = new Redis(redisUrl, { maxRetriesPerRequest: null });
102
106
  const cacheSync = createRedisCacheSyncBus({
103
107
  redisUrl,
@@ -136,6 +140,16 @@ export async function bootWorkerProcess(options, envSource, profile) {
136
140
  throw err;
137
141
  }
138
142
  }
143
+ try {
144
+ await assertRowLevelSecurityBootState(db, rowLevelSecurity, knownTablesOf(features));
145
+ }
146
+ catch (err) {
147
+ await closeDb();
148
+ await closeOutsideTransactionDb();
149
+ redis.disconnect();
150
+ await cacheSync.close();
151
+ throw err;
152
+ }
139
153
  const idempotency = createIdempotencyGuard(redis, { ttlSeconds: 60 });
140
154
  const eventDedup = createEventDedup(redis, { ttlSeconds: 60 });
141
155
  const entityCache = createEntityCache(redis, { ttlSeconds: 60 });
@@ -149,6 +163,7 @@ export async function bootWorkerProcess(options, envSource, profile) {
149
163
  configurePiiSubjectKms(options.kms);
150
164
  configureBlindIndexKey(options.blindIndexKey);
151
165
  const bootLogger = loggerFromExtraContext(resolvedExtraContext);
166
+ warnIfRowLevelSecurityOff(rowLevelSecurity, bootLogger ?? createFallbackLogger(processName));
152
167
  const autoExtraContext = buildBootExtraContext({
153
168
  db,
154
169
  features,
@@ -166,6 +181,7 @@ export async function bootWorkerProcess(options, envSource, profile) {
166
181
  const jobLogger = jobRunLoggerCallbacks(registry, db);
167
182
  const entrypoint = createWorkerEntrypoint({
168
183
  registry,
184
+ rowLevelSecurity,
169
185
  context: { db, redis, entityCache, registry, ...extraContext },
170
186
  jwtSecret: jwtSecretOrKeyring,
171
187
  dispatcherOptions: {
@@ -197,6 +213,7 @@ export async function bootWorkerProcess(options, envSource, profile) {
197
213
  registry,
198
214
  features,
199
215
  envSource,
216
+ extraContext,
200
217
  entrypoint,
201
218
  ...(isSecretsContext(extraContext["secrets"]) && { secrets: extraContext["secrets"] }),
202
219
  ...(bootCrypto.configCipher && { configCipher: bootCrypto.configCipher }),
@@ -1,6 +1,7 @@
1
1
  // /metrics for HTTP-less worker pods: the worker entrypoint has no Hono app,
2
2
  // so scraping needs its own tiny server on a dedicated port.
3
3
  import { createMetricsApp, isPrometheusMeter, } from "@cosmicdrift/kumiko-framework/api";
4
+ import { assertNoActiveRowLevelSecurityScope } from "@cosmicdrift/kumiko-framework/db";
4
5
  /** Fails the boot (also under KUMIKO_DRY_RUN_ENV=boot) on a metrics config
5
6
  * that would otherwise only surface as a 503 on the first scrape. */
6
7
  export function assertWorkerMetricsOptions(metrics, observability, processName) {
@@ -16,6 +17,7 @@ export function assertWorkerMetricsOptions(metrics, observability, processName)
16
17
  }
17
18
  export function startWorkerMetricsServer(metrics, entrypoint) {
18
19
  const app = createMetricsApp(entrypoint.observability.meter, metrics);
20
+ assertNoActiveRowLevelSecurityScope("worker metrics server");
19
21
  const server = Bun.serve({ port: metrics.port, fetch: app.fetch });
20
22
  entrypoint.lifecycle.registerShutdownHook("metrics-server", async () => {
21
23
  await server.stop();
@@ -28,7 +28,9 @@ function membershipOf(tenant, roles) {
28
28
  }
29
29
  function seedAccount(admin) {
30
30
  return async (db) => {
31
- await seedAdminGuarded(db, admin);
31
+ // Operator-created account, not a self-registration: under emailVerificationMode "strict"
32
+ // an unverified seed account could never log in. An explicit `emailVerified` still overrides.
33
+ await seedAdminGuarded(db, { emailVerified: true, ...admin });
32
34
  };
33
35
  }
34
36
  function buildDemoAdminSeed(value, demoTenant, envSource) {
@@ -26,6 +26,9 @@ import { FILES_FEATURE } from "@cosmicdrift/kumiko-framework/files";
26
26
  import { createRendererMailHtmlFeature } from "@cosmicdrift/kumiko-renderer-mail-html";
27
27
  import { applyBundledListsTable } from "./bundled-lists-table.js";
28
28
  export { buildComposeAuthOptions } from "./compose-auth-options.js";
29
+ // Output of a first composeFeatures pass comes back as app features when a caller composes twice (the app
30
+ // config already composed, boot composes again); those copies are dropped silently, only app-supplied ones warn.
31
+ const composedBundledInstances = new WeakSet();
29
32
  export function composeFeatures(appFeatures, options) {
30
33
  // ponytail: includeBundled:false skips the auth-mfa auto-wiring below —
31
34
  // an app composing its own foundation and mounting auth-mfa itself is
@@ -74,6 +77,8 @@ export function composeFeatures(appFeatures, options) {
74
77
  // "on unless an operator flips it off at runtime" contract.
75
78
  ...(authOptions?.signup !== undefined ? [createAuthSelfRegistrationToggleFeature()] : []),
76
79
  ];
80
+ for (const f of bundled)
81
+ composedBundledInstances.add(f);
77
82
  const bundledNames = new Set(bundled.map((f) => f.name));
78
83
  const filteredApp = [];
79
84
  for (const f of dedupedAppFeatures) {
@@ -83,6 +88,8 @@ export function composeFeatures(appFeatures, options) {
83
88
  // authOptions.signup and only get it from there.
84
89
  if (f.name === AUTH_SELF_REGISTRATION_FEATURE)
85
90
  continue;
91
+ if (composedBundledInstances.has(f))
92
+ continue;
86
93
  // biome-ignore lint/suspicious/noConsole: boot-time UX warning
87
94
  console.warn(`[composeFeatures] "${f.name}" already auto-mounted via includeBundled — dropping the explicit copy from APP_FEATURES. Remove it from run-config.ts to silence this warning.`);
88
95
  continue;
@@ -26,7 +26,7 @@ export type SystemWireDeps = {
26
26
  readonly masterKeyProvider?: MasterKeyProvider;
27
27
  /** Cipher for `encrypted: true` config keys, same instance and DEK cache as the handlers use. */
28
28
  readonly configEncryption?: EnvelopeCipher;
29
- /** Same contract as `ctx.metricsFor`: unregistered metric names no-op. */
29
+ /** Same contract as `ctx.obs.metricsFor`: unregistered metric names no-op. */
30
30
  readonly metricsFor: (featureName: string) => MetricsHandle;
31
31
  /** Writes through the /api/*-command-dispatcher (same idempotency/job
32
32
  * hooks) as an auto-constructed SystemAdmin of the target tenant,
@@ -23,13 +23,11 @@ import { resolveBootCrypto } from "./boot/boot-crypto.js";
23
23
  export function addConfigAccessorFactory(resolved, registry) {
24
24
  if (!resolved.configResolver)
25
25
  return resolved;
26
- // Both keys, always overriding an app-supplied value: the factory must derive from the
27
- // effective resolver; the underscore alias stays until the next major.
26
+ // Always overrides an app-supplied value: the factory must derive from the effective resolver.
28
27
  const configAccessorFactory = createConfigAccessorFactory(registry, resolved.configResolver);
29
28
  return {
30
29
  ...resolved,
31
30
  configAccessorFactory,
32
- _configAccessorFactory: configAccessorFactory,
33
31
  };
34
32
  }
35
33
  // Framework-default providers for AppContext, using the same mechanism as
@@ -42,7 +40,7 @@ export function addConfigAccessorFactory(resolved, registry) {
42
40
  // boot/boot-crypto.ts (envHasMasterKek, resolveBootCrypto), shared with runDevApp.
43
41
  // A production misconfiguration (secrets mounted without a KEK) is caught by
44
42
  // secretsEnvSchema during boot; without the env-schema path, requireSecretsContext
45
- // throws with a wiring hint on the first ctx.secrets access. configResolver is only
43
+ // throws with a wiring hint on the first ctx.services.secrets access. configResolver is only
46
44
  // wired in auth mode. Exported and pure for unit tests; the caller merges it with
47
45
  // app values (the app wins).
48
46
  // Prod/dev parity for ctx.notify: without this `_notifyFactory` is only wired
@@ -116,7 +114,7 @@ function bootCryptoContext(crypto) {
116
114
  ...(crypto.masterKeyProvider && { masterKeyProvider: crypto.masterKeyProvider }),
117
115
  // Encrypt/decrypt partner for `encrypted: true` config keys. Wired
118
116
  // whenever a master key exists — NOT gated on the secrets feature,
119
- // config encryption must work without mounting ctx.secrets.
117
+ // config encryption must work without mounting ctx.services.secrets.
120
118
  ...(crypto.configCipher && { configEncryption: crypto.configCipher }),
121
119
  };
122
120
  }
@@ -1,4 +1,5 @@
1
1
  import { loadJwtSecretOrKeyring } from "@cosmicdrift/kumiko-framework/api";
2
+ import type { RowLevelSecurityMode } from "@cosmicdrift/kumiko-framework/db";
2
3
  import { type FeatureDefinition, type Registry } from "@cosmicdrift/kumiko-framework/engine";
3
4
  import { type Logger } from "@cosmicdrift/kumiko-framework/logging";
4
5
  import { resolveBootCrypto } from "./boot/boot-crypto.ts";
@@ -15,6 +16,7 @@ export type ProdBootConfig = {
15
16
  readonly envSource: Record<string, string | undefined>;
16
17
  readonly bootKms: BootOptionWiring["kms"];
17
18
  readonly databaseUrl: string;
19
+ readonly rowLevelSecurity: RowLevelSecurityMode;
18
20
  readonly redisUrl: string;
19
21
  readonly jwtSecret: string;
20
22
  readonly jwtSecretOrKeyring: ReturnType<typeof loadJwtSecretOrKeyring>;
@@ -2,6 +2,7 @@ import { EXT_SESSION_STORE } from "@cosmicdrift/kumiko-bundled-features/auth-fou
2
2
  import { SESSIONS_FEATURE } from "@cosmicdrift/kumiko-bundled-features/sessions";
3
3
  import { loadJwtSecretOrKeyring, TRUSTED_PROXY_HOPS_ENV } from "@cosmicdrift/kumiko-framework/api";
4
4
  import { createRegistry, } from "@cosmicdrift/kumiko-framework/engine";
5
+ import { DEFAULT_ROW_LEVEL_SECURITY_MODE } from "@cosmicdrift/kumiko-framework/internal/row-level-security";
5
6
  import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
6
7
  import { resolveBootCrypto } from "./boot/boot-crypto.js";
7
8
  import { composeAppFeatures, validateComposedFeatures, withEnvDefaults, } from "./boot/shared-boot-steps.js";
@@ -100,6 +101,7 @@ export async function resolveProdBootConfig(options, decryptedEnvSource, log, re
100
101
  envSource,
101
102
  bootKms,
102
103
  databaseUrl,
104
+ rowLevelSecurity: options.rowLevelSecurity ?? DEFAULT_ROW_LEVEL_SECURITY_MODE,
103
105
  redisUrl,
104
106
  jwtSecret,
105
107
  jwtSecretOrKeyring,
@@ -8,7 +8,7 @@ import { WAIT_FOR_DATABASE_DEADLINE_MS, waitForDatabase, } from "./boot/wait-for
8
8
  // immediately. `new Redis(...)` connects eagerly, so this must stay AFTER the
9
9
  // boot-mode exit.
10
10
  export async function connectProdDependencies(config, log, registerAbortCleanup, databaseWait) {
11
- const { bootKms, databaseUrl, redisUrl, envSource } = config;
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.
@@ -16,7 +16,7 @@ export async function connectProdDependencies(config, log, registerAbortCleanup,
16
16
  if (bootKms.kms) {
17
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);
@@ -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,4 +1,5 @@
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";
@@ -27,6 +28,7 @@ export function buildProdEntrypointOptions(options, config, composed, connection
27
28
  registry,
28
29
  ...requestContext.extraContext,
29
30
  },
31
+ rowLevelSecurity: config.rowLevelSecurity,
30
32
  sseBroker: requestContext.sseBroker,
31
33
  jwtSecret: config.jwtSecretOrKeyring,
32
34
  ...(jwtIssuer && { jwtIssuer }),
@@ -119,12 +121,12 @@ export function registerProdShutdownHooks(entrypoint, cacheSync, ownedRedisSseBr
119
121
  // runs. `wire` is the remaining escape-hatch for app-wired co-running
120
122
  // components that need the system-write dispatcher but don't declare a
121
123
  // route.
122
- export async function runProdWireHook(options, composed, connections, entrypoint) {
124
+ export async function runProdWireHook(options, config, composed, connections, entrypoint) {
123
125
  const { wire } = options;
124
126
  if (wire) {
125
127
  const { bootCrypto } = composed;
126
128
  await wire({
127
- db: connections.db,
129
+ db: appHookScopedRunner(connections.db, config.rowLevelSecurity),
128
130
  redis: connections.redis,
129
131
  registry: composed.registry,
130
132
  filesForTenant: makeFilesForTenant(entrypoint.context),
@@ -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";
@@ -65,6 +66,7 @@ export function createProdAppHandle(input) {
65
66
  throw new Error("[runProdApp] listen() requires Bun runtime (Bun.serve). " +
66
67
  "Under Node/vitest pass `autoListen: false` and call the returned `fetch()` directly.");
67
68
  }
69
+ assertNoActiveRowLevelSecurityScope("prod app server");
68
70
  handle.server = Bun.serve(buildBunServeOptions(listenPort, fetchHandler, options.maxRequestBodySize ?? resolveDerivedMaxRequestBodySize(composed.registry), { upgradeFetch: webSocketUpgradeFetch }));
69
71
  // SIGTERM/SIGINT — graceful shutdown. Only registered when we
70
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,31 +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,
68
- processName: "runProdApp",
69
- ...(options.databaseWait && { databaseWait: options.databaseWait }),
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
+ }
70
100
  });
71
- const hasSeeds = config.bootOptionWiring.customSeeds.length > 0 || options.seedsDir !== undefined;
72
- const seedContext = hasSeeds ? buildSeedContext(run) : undefined;
73
- const seedDispatcher = seedContext
74
- ? withSeedErrorContext(createDispatcher(composed.registry, seedContext, {
75
- ...dispatcherOptions,
76
- lifecycle: entrypoint.lifecycleHooks,
77
- ...(entrypoint.mode === "all-in-one" && { jobRunner: entrypoint.jobRunner }),
78
- }))
79
- : undefined;
80
- if (seedDispatcher && seedContext) {
81
- await runCustomSeeds(run, seedContext, seedDispatcher);
82
- }
83
- if (seedDispatcher &&
84
- options.seedsDir !== undefined &&
85
- config.envSource["KUMIKO_SKIP_ES_OPS"] !== "1") {
86
- await runSeedMigrations(run, options.seedsDir, seedDispatcher);
87
- }
88
101
  }
@@ -3,6 +3,7 @@ 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 RowLevelSecurityMode } from "@cosmicdrift/kumiko-framework/db";
6
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";
@@ -12,6 +13,7 @@ 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";
15
17
  import type { DatabaseWaitClock } from "./boot/wait-for-database.ts";
16
18
  import type { BillingBootOption } from "./boot-options/billing-option.ts";
17
19
  import type { DemoModeOption } from "./boot-options/demo-mode.ts";
@@ -266,6 +268,13 @@ export type RunProdAppOptions = {
266
268
  readonly validateBootOptions?: ValidateBootOptions;
267
269
  /** One table block for every list of the bundled features, see ComposeFeaturesOptions.bundledLists. */
268
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;
269
278
  /** Listen-Port. Default 3000 (or $PORT). */
270
279
  readonly port?: number;
271
280
  /** Ceiling Bun.serve buffers a request body to before any app-level
@@ -342,13 +351,13 @@ export type RunProdAppOptions = {
342
351
  readonly defaultLocale?: string;
343
352
  readonly extraContext?: ExtraContextOption;
344
353
  /** MasterKeyProvider replacing the env KEK (`KUMIKO_SECRETS_MASTER_KEY_V<n>`)
345
- * for `ctx.secrets`, encrypted config keys and `encrypted: true` entity
354
+ * for `ctx.services.secrets`, encrypted config keys and `encrypted: true` entity
346
355
  * fields. Default: `createEnvMasterKeyProvider`. Use it for KMS backends
347
356
  * (AWS/GCP/Azure); with it set, `KUMIKO_SECRETS_MASTER_KEY_V1` is not
348
357
  * required in the env. */
349
358
  readonly masterKey?: MasterKeyProvider;
350
359
  /** Subject-key adapter for crypto-shredding (GDPR Art. 17). When set, it is
351
- * 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
352
361
  * cannot start against an unreachable KMS. Without an adapter,
353
362
  * crypto-shredding remains disabled. */
354
363
  readonly kms?: KmsAdapter;
@@ -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";
@@ -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,6 +1,6 @@
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";
3
+ import type { DbConnection, RowLevelSecurityMode } from "@cosmicdrift/kumiko-framework/db";
4
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";
@@ -8,6 +8,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";
11
12
  import type { DatabaseWaitClock } from "./boot/wait-for-database.ts";
12
13
  import { type WorkerMetricsOptions, type WorkerMetricsServer } from "./boot/worker-metrics-server.ts";
13
14
  import type { BillingBootOption } from "./boot-options/billing-option.ts";
@@ -29,13 +30,20 @@ export type WorkerDeps = {
29
30
  * `lifecycle` for `registerShutdownHook`. */
30
31
  export type WorkerWireDeps = SystemWireDeps & {
31
32
  readonly lifecycle: WorkerEntrypoint["lifecycle"];
32
- /** The worker's ctx.secrets — what `createInboundMailSupervisor` needs as
33
+ /** The worker's ctx.services.secrets — what `createInboundMailSupervisor` needs as
33
34
  * `providerCtx.secrets` (the IMAP provider reads credentials through it,
34
35
  * with `auditUserId` set to a system identity for the audit trail). Undefined
35
36
  * when the secrets feature is not mounted or no master key is available. */
36
37
  readonly secrets?: SecretsContext;
37
38
  };
38
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;
39
47
  /** App-specific features — same array as in the API/all-in-one process,
40
48
  * so the registry + schema stay identical across processes. */
41
49
  readonly features: readonly FeatureDefinition[];
@@ -58,7 +66,7 @@ export type RunWorkerAppOptions = {
58
66
  * RunProdAppOptions["extraContext"], without sseBroker (the worker
59
67
  * has none — see entrypoint/index.ts's documented SSE limitation). */
60
68
  readonly extraContext?: WorkerContextOption;
61
- /** MasterKeyProvider replacing the env KEK for ctx.secrets, encrypted
69
+ /** MasterKeyProvider replacing the env KEK for ctx.services.secrets, encrypted
62
70
  * config keys and `encrypted: true` entity fields; with it set,
63
71
  * `KUMIKO_SECRETS_MASTER_KEY_V1` is not required (see
64
72
  * RunProdAppOptions["masterKey"]). */
@@ -15,12 +15,14 @@
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";
@@ -49,6 +51,22 @@ async function resolveGatedEnvSource(options) {
49
51
  function assertWorkerStackRequirements(options) {
50
52
  assertStackBootRequirements({ features: options.features, ...(options.featureToggles && { featureToggles: true }) }, ["featureToggles"]);
51
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
+ }
52
70
  export async function runWorkerApp(options) {
53
71
  assertSeedModeNotInProduction();
54
72
  const resolvedEnvSource = await resolveGatedEnvSource(options);
@@ -92,9 +110,7 @@ export async function runWorkerApp(options) {
92
110
  // hook, so a failing start() does not leak the listener.
93
111
  let metricsServer;
94
112
  try {
95
- metricsServer = options.metrics
96
- ? startWorkerMetricsServer(options.metrics, entrypoint)
97
- : undefined;
113
+ metricsServer = await backfillThenStartMetrics(boot, options);
98
114
  await entrypoint.start();
99
115
  }
100
116
  catch (error) {
@@ -125,7 +141,7 @@ export async function runWorkerApp(options) {
125
141
  });
126
142
  if (options.wireComponents) {
127
143
  await options.wireComponents({
128
- db,
144
+ db: wireHookDb(db, options),
129
145
  redis,
130
146
  registry,
131
147
  filesForTenant: makeFilesForTenant(entrypoint.context),
@@ -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.389.0",
3
+ "version": "0.390.1",
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>",
@@ -96,20 +96,20 @@
96
96
  }
97
97
  },
98
98
  "dependencies": {
99
- "@cosmicdrift/kumiko-bundled-features": "0.389.0",
100
- "@cosmicdrift/kumiko-framework": "0.389.0",
101
- "@cosmicdrift/kumiko-renderer": "0.389.0",
102
- "@cosmicdrift/kumiko-renderer-mail-html": "0.389.0",
103
- "@cosmicdrift/kumiko-types": "0.389.0",
99
+ "@cosmicdrift/kumiko-bundled-features": "0.390.1",
100
+ "@cosmicdrift/kumiko-framework": "0.390.1",
101
+ "@cosmicdrift/kumiko-renderer": "0.390.1",
102
+ "@cosmicdrift/kumiko-renderer-mail-html": "0.390.1",
103
+ "@cosmicdrift/kumiko-types": "0.390.1",
104
104
  "ioredis": "^5.10.1",
105
105
  "temporal-polyfill": "^0.3.2",
106
- "zod": "^4.4.3"
106
+ "zod": "^4.6.7"
107
107
  },
108
108
  "peerDependencies": {
109
109
  "bun-types": "^1.4.4"
110
110
  },
111
111
  "devDependencies": {
112
- "@cosmicdrift/kumiko-testing": "0.389.0",
112
+ "@cosmicdrift/kumiko-testing": "0.390.1",
113
113
  "bun-types": "^1.4.4"
114
114
  },
115
115
  "publishConfig": {
package/src/changes.json CHANGED
@@ -1,4 +1,35 @@
1
1
  [
2
+ {
3
+ "version": "0.390.0",
4
+ "type": "fix",
5
+ "title": "auth.admin seed admin is email-verified",
6
+ "detail": "Under emailVerificationMode strict the seeded admin failed login with email_not_verified."
7
+ },
8
+ {
9
+ "version": "0.390.0",
10
+ "type": "improvement",
11
+ "title": "runProdApp checks the row level security state at boot",
12
+ "detail": "RunProdAppOptions.rowLevelSecurity (\"off\" by default) sets the mode of every connection; the scaffolded server entry passes appConfig.rowLevelSecurity. With \"on\", boot fails when the database role has SUPERUSER or BYPASSRLS, because Postgres then skips every policy, and when a table with tenant_id lacks ENABLE, FORCE or one of the two kumiko policies; the message lists the tables. With \"off\", boot fails while any table still has row level security enabled and points to the DISABLE migration that kumiko-schema generate writes."
13
+ },
14
+ {
15
+ "version": "0.390.0",
16
+ "type": "fix",
17
+ "title": "demoAdminFromEnv seed account is email-verified",
18
+ "detail": "Under emailVerificationMode strict the seeded demo admin failed login with email_not_verified."
19
+ },
20
+ {
21
+ "version": "0.390.0",
22
+ "type": "improvement",
23
+ "title": "Wiring tests reset event-sourced tables through resetTestTables",
24
+ "detail": "The compose-features integration tests clear users, memberships and the event log with resetTestTables instead of deleting projection rows. Nothing changes for apps."
25
+ },
26
+ {
27
+ "version": "0.390.0",
28
+ "type": "improvement",
29
+ "title": "search \"none\" lets apps without a search backend run the stream-tenant backfill",
30
+ "detail": "The backfill deferred itself in every process without a search adapter, so apps with a searchable tenant entity but no search backend (for example publicstatus) never moved their legacy tenant streams.",
31
+ "migration": "Apps without a search backend set `search: \"none\"` on runProdApp and runWorkerApp, otherwise the stream-tenant backfill stays deferred."
32
+ },
2
33
  {
3
34
  "version": "0.389.0",
4
35
  "type": "fix",