@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
@@ -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,17 +1,18 @@
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";
5
- import { type EffectiveFeaturesResolver, type FeatureDefinition, type Registry, type ValidateBootOptions } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { type DbConnection, type RowLevelSecurityMode } from "@cosmicdrift/kumiko-framework/db";
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";
8
8
  import type { FeatureTogglesWiring } from "../boot-options/feature-toggles-option.ts";
9
9
  import type { DbSeedFn } from "../boot-options/seed-shortcuts.ts";
10
10
  import { type AuthOptionsCarrier } from "../compose-features.ts";
11
11
  import type { RunProdAppAuthOptions } from "../run-prod-app.ts";
12
+ import { type DatabaseWaitClock } from "./wait-for-database.ts";
12
13
  /** Returns `envSource` itself (it may be `process.env`) when there is nothing to layer under it. */
13
14
  export declare function withEnvDefaults(envDefaults: Readonly<Record<string, string | undefined>>, envSource: Record<string, string | undefined>): Record<string, string | undefined>;
14
- export declare function composeAppFeatures(appFeatures: readonly FeatureDefinition[], bootFeatures: readonly FeatureDefinition[], effectiveAuth: AuthOptionsCarrier | undefined): readonly FeatureDefinition[];
15
+ export declare function composeAppFeatures(appFeatures: readonly FeatureDefinition[], bootFeatures: readonly FeatureDefinition[], effectiveAuth: AuthOptionsCarrier | undefined, bundledLists?: BundledListsOptions): readonly FeatureDefinition[];
15
16
  export declare function validateComposedFeatures(features: readonly FeatureDefinition[], bootOptions: ValidateBootOptions): void;
16
17
  export declare function assertMfaSwitchTenantGate(registry: Registry, processName: string): void;
17
18
  export declare function buildEffectiveFeaturesResolver(deps: {
@@ -27,14 +28,16 @@ export declare function applyStandardBootSeeds(deps: {
27
28
  readonly admin: SeedAdminOptions | undefined;
28
29
  readonly shortcutSeeds: readonly DbSeedFn[];
29
30
  readonly cipher: EnvelopeCipher | undefined;
31
+ readonly processName: string;
32
+ readonly databaseWait?: DatabaseWaitClock;
30
33
  }): Promise<void>;
31
34
  type SharedAuthFlowFields = Pick<RunProdAppAuthOptions, "passwordReset" | "emailVerification" | "accountUnlock" | "signup" | "invite">;
32
35
  type AuthFlowRoutes = Pick<AuthRoutesConfig, "passwordReset" | "emailVerification" | "accountUnlock" | "signup" | "invite">;
33
36
  export type SharedAuthRouteSource = SharedAuthFlowFields & Pick<RunProdAppAuthOptions, "loginErrorStatusMap" | "cookieDomain" | "retiredCookieDomains" | "allowedOrigins" | "unsafeSkipOriginCheck" | "postAuthLanding">;
34
37
  export type SharedAuthRoutes = AuthFlowRoutes & Pick<AuthRoutesConfig, "membershipQuery" | "loginHandler" | "loginErrorStatusMap" | "cookieDomain" | "retiredCookieDomains" | "allowedOrigins" | "unsafeSkipOriginCheck" | "postAuthLanding" | "mfaVerifyHandler" | "switchTenantMfaGateHandler" | "mfaPreauthEnableStartHandler" | "mfaPreauthConfirmHandler">;
35
38
  export declare function buildSharedAuthRoutes(effectiveAuth: SharedAuthRouteSource, hasMfa: boolean): SharedAuthRoutes;
36
- export declare function waitForKmsHealthy(kms: KmsAdapter, processName: string, deadline: number): Promise<void>;
37
- export declare function openDatabasePools(databaseUrl: string, envSource: Record<string, string | undefined>): {
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>, rowLevelSecurity: RowLevelSecurityMode): {
38
41
  readonly db: DbConnection;
39
42
  readonly closeDb: () => Promise<void>;
40
43
  readonly outsideTransactionDb: DbConnection;
@@ -15,11 +15,12 @@ import { waitUntilDatabaseReachable } from "./wait-for-database.js";
15
15
  export function withEnvDefaults(envDefaults, envSource) {
16
16
  return Object.keys(envDefaults).length > 0 ? { ...envDefaults, ...envSource } : envSource;
17
17
  }
18
- export function composeAppFeatures(appFeatures, bootFeatures, effectiveAuth) {
18
+ export function composeAppFeatures(appFeatures, bootFeatures, effectiveAuth, bundledLists) {
19
19
  const composeAuthOptions = buildComposeAuthOptions(effectiveAuth);
20
20
  return composeFeatures([...appFeatures, ...bootFeatures], {
21
21
  includeBundled: !!effectiveAuth,
22
22
  ...(composeAuthOptions && { authOptions: composeAuthOptions }),
23
+ ...(bundledLists && { bundledLists }),
23
24
  });
24
25
  }
25
26
  export function validateComposedFeatures(features, bootOptions) {
@@ -46,19 +47,25 @@ export async function buildEffectiveFeaturesResolver(deps) {
46
47
  return bound.resolver;
47
48
  }
48
49
  export async function applyStandardBootSeeds(deps) {
49
- const { db, registry, admin, shortcutSeeds, cipher } = deps;
50
- if (admin) {
51
- await seedAdminGuarded(db, admin);
52
- }
53
- for (const seed of shortcutSeeds) {
54
- await seed(db);
55
- }
56
- // The helper indirection is what config-seed-boot.integration.ts pins — keep it as a single call.
57
- await applyBootSeeds({
58
- registry,
59
- db,
60
- ...(cipher && { cipher }),
61
- });
50
+ const { db, registry, admin, shortcutSeeds, cipher, processName, databaseWait } = deps;
51
+ // Seeds are idempotent, so a transient connection error re-runs the whole sequence; any other
52
+ // error stays fatal. Later boot steps build on the seeds, so this cannot continue in the background.
53
+ await waitUntilDatabaseReachable(async () => {
54
+ if (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 });
58
+ }
59
+ for (const seed of shortcutSeeds) {
60
+ await seed(db);
61
+ }
62
+ // The helper indirection is what config-seed-boot.integration.ts pins — keep it as a single call.
63
+ await applyBootSeeds({
64
+ registry,
65
+ db,
66
+ ...(cipher && { cipher }),
67
+ });
68
+ }, { ...databaseWait, processName, target: "database (boot seeds)" });
62
69
  }
63
70
  const MFA_ROUTE_HANDLERS = {
64
71
  mfaVerifyHandler: AuthMfaHandlers.verify,
@@ -129,8 +136,9 @@ export function buildSharedAuthRoutes(effectiveAuth, hasMfa) {
129
136
  ...buildAuthFlowRoutes(effectiveAuth),
130
137
  };
131
138
  }
132
- export async function waitForKmsHealthy(kms, processName, deadline) {
139
+ export async function waitForKmsHealthy(kms, processName, deadline, clock) {
133
140
  const kmsHealth = await waitUntilDatabaseReachable(() => kms.health(), {
141
+ ...clock,
134
142
  processName,
135
143
  target: "KMS",
136
144
  deadline,
@@ -139,14 +147,16 @@ export async function waitForKmsHealthy(kms, processName, deadline) {
139
147
  throw new Error(`[${processName}] BOOT ABORTED — KMS health check failed (latency ${kmsHealth.latencyMs}ms)`);
140
148
  }
141
149
  }
142
- export function openDatabasePools(databaseUrl, envSource) {
150
+ export function openDatabasePools(databaseUrl, envSource, rowLevelSecurity) {
143
151
  const { db, close: closeDb } = createDbConnection({
144
152
  url: databaseUrl,
153
+ rowLevelSecurity,
145
154
  ...dbConnectionOptionsFromEnv(envSource),
146
155
  ...serverTimeoutOptionsFromEnv(envSource),
147
156
  });
148
157
  const { db: outsideTransactionDb, close: closeOutsideTransactionDb } = createDbConnection({
149
158
  url: databaseUrl,
159
+ rowLevelSecurity,
150
160
  ...outsideTransactionPoolOptionsFromEnv(envSource),
151
161
  ...serverTimeoutOptionsFromEnv(envSource),
152
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
+ }
@@ -12,5 +12,7 @@ export type WaitForDatabaseOptions = {
12
12
  readonly now?: () => number;
13
13
  readonly deadline?: number;
14
14
  };
15
+ /** Injectable clock and sink; tests drive the backoff without real waiting. */
16
+ export type DatabaseWaitClock = Pick<WaitForDatabaseOptions, "log" | "sleep" | "now">;
15
17
  export declare function waitForDatabase(db: DbConnection, options: WaitForDatabaseOptions): Promise<void>;
16
18
  export declare function waitUntilDatabaseReachable<T>(ping: () => Promise<T>, options: WaitForDatabaseOptions): Promise<T>;
@@ -7,9 +7,10 @@ const TRANSIENT_CODES = new Set([
7
7
  "ETIMEDOUT",
8
8
  "ENOTFOUND",
9
9
  "EAI_AGAIN",
10
- "CONNECTION_CLOSED",
11
- "CONNECT_TIMEOUT",
12
- "CONNECTION_DESTROYED",
10
+ "ERR_POSTGRES_CONNECTION_CLOSED",
11
+ "ERR_POSTGRES_CONNECTION_REFUSED",
12
+ "ERR_POSTGRES_CONNECTION_TIMEOUT",
13
+ "ERR_POSTGRES_CONNECTION_FAILED",
13
14
  ]);
14
15
  const CONNECTION_EXCEPTION_CLASS = "08";
15
16
  export function isTransientConnectionError(error) {
@@ -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,11 +65,13 @@ 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, {
68
72
  includeBundled,
69
73
  ...(profile.authOptions && { authOptions: profile.authOptions }),
74
+ ...(options.bundledLists && { bundledLists: options.bundledLists }),
70
75
  });
71
76
  const bootCrypto = resolveBootCrypto(envSource, options.masterKey);
72
77
  validateBoot(features, {
@@ -92,18 +97,22 @@ export async function bootWorkerProcess(options, envSource, profile) {
92
97
  return { kind: "dry-run", envSource };
93
98
  }
94
99
  // Before the connections, so an abort leaks nothing.
95
- const databaseWaitDeadline = Date.now() + WAIT_FOR_DATABASE_DEADLINE_MS;
100
+ const databaseWaitDeadline = (options.databaseWait?.now ?? Date.now)() + WAIT_FOR_DATABASE_DEADLINE_MS;
96
101
  if (options.kms) {
97
- await waitForKmsHealthy(options.kms, processName, databaseWaitDeadline);
102
+ await waitForKmsHealthy(options.kms, processName, databaseWaitDeadline, options.databaseWait);
98
103
  }
99
- const { db, closeDb, outsideTransactionDb, closeOutsideTransactionDb } = openDatabasePools(databaseUrl, envSource);
104
+ const { db, closeDb, outsideTransactionDb, closeOutsideTransactionDb } = openDatabasePools(databaseUrl, envSource, rowLevelSecurity);
100
105
  const redis = new Redis(redisUrl, { maxRetriesPerRequest: null });
101
106
  const cacheSync = createRedisCacheSyncBus({
102
107
  redisUrl,
103
108
  clientOptions: redisClientOptionsFromEnv(envSource),
104
109
  });
105
110
  try {
106
- await waitForDatabase(db, { processName, deadline: databaseWaitDeadline });
111
+ await waitForDatabase(db, {
112
+ ...options.databaseWait,
113
+ processName,
114
+ deadline: databaseWaitDeadline,
115
+ });
107
116
  }
108
117
  catch (err) {
109
118
  await closeDb();
@@ -131,6 +140,16 @@ export async function bootWorkerProcess(options, envSource, profile) {
131
140
  throw err;
132
141
  }
133
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
+ }
134
153
  const idempotency = createIdempotencyGuard(redis, { ttlSeconds: 60 });
135
154
  const eventDedup = createEventDedup(redis, { ttlSeconds: 60 });
136
155
  const entityCache = createEntityCache(redis, { ttlSeconds: 60 });
@@ -144,6 +163,7 @@ export async function bootWorkerProcess(options, envSource, profile) {
144
163
  configurePiiSubjectKms(options.kms);
145
164
  configureBlindIndexKey(options.blindIndexKey);
146
165
  const bootLogger = loggerFromExtraContext(resolvedExtraContext);
166
+ warnIfRowLevelSecurityOff(rowLevelSecurity, bootLogger ?? createFallbackLogger(processName));
147
167
  const autoExtraContext = buildBootExtraContext({
148
168
  db,
149
169
  features,
@@ -151,6 +171,7 @@ export async function bootWorkerProcess(options, envSource, profile) {
151
171
  registry,
152
172
  hasAuth: includeBundled,
153
173
  redis,
174
+ ...(options.appOverrides && { appOverrides: options.appOverrides }),
154
175
  crypto: bootCrypto,
155
176
  ...(options.kms && { kms: options.kms }),
156
177
  ...(profile.deliverQueuedInline === true && { deliverQueuedInline: true }),
@@ -160,6 +181,7 @@ export async function bootWorkerProcess(options, envSource, profile) {
160
181
  const jobLogger = jobRunLoggerCallbacks(registry, db);
161
182
  const entrypoint = createWorkerEntrypoint({
162
183
  registry,
184
+ rowLevelSecurity,
163
185
  context: { db, redis, entityCache, registry, ...extraContext },
164
186
  jwtSecret: jwtSecretOrKeyring,
165
187
  dispatcherOptions: {
@@ -191,6 +213,7 @@ export async function bootWorkerProcess(options, envSource, profile) {
191
213
  registry,
192
214
  features,
193
215
  envSource,
216
+ extraContext,
194
217
  entrypoint,
195
218
  ...(isSecretsContext(extraContext["secrets"]) && { secrets: extraContext["secrets"] }),
196
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();
@@ -1,4 +1,4 @@
1
- import type { ExtraRouteDefinition } from "@cosmicdrift/kumiko-framework/api";
1
+ import { type ExtraRouteDefinition } from "@cosmicdrift/kumiko-framework/api";
2
2
  export type DemoAccountRole = "admin" | "sysadmin";
3
3
  /** A role alone, or a role with the label the banner shows next to the login. */
4
4
  export type DemoAccountEntry = DemoAccountRole | {
@@ -19,7 +19,7 @@ export type DemoModeNotice = {
19
19
  readonly accounts?: readonly DemoAccountEntry[];
20
20
  };
21
21
  /** Live-demo switch. `readOnly: true` answers every mutating request
22
- * (POST/PUT/PATCH/DELETE) outside `/api/query` and the login/logout/MFA
22
+ * (POST/PUT/PATCH/DELETE) and every WebSocket upgrade outside `/api/query` and the login/logout/MFA
23
23
  * routes with 403 `demo_read_only`. `GET /demo-mode` (anonymous) returns
24
24
  * `{ readOnly, notice }` for the client banner.
25
25
  *
@@ -32,7 +32,7 @@ export type DemoModeNotice = {
32
32
  export type DemoModeOption = {
33
33
  readonly readOnly: boolean;
34
34
  readonly notice?: DemoModeNotice;
35
- /** Mutating paths that stay open besides the default allowlist. */
35
+ /** Mutating paths (and WebSocket route paths) that stay open besides the default allowlist. */
36
36
  readonly allowedWritePaths?: readonly string[];
37
37
  };
38
38
  export declare const DEMO_MODE_PATH = "/demo-mode";
@@ -1,3 +1,4 @@
1
+ import { isWebSocketUpgradeRequest, } from "@cosmicdrift/kumiko-framework/api";
1
2
  export const DEMO_MODE_PATH = "/demo-mode";
2
3
  export const DEMO_READ_ONLY_ERROR_CODE = "demo_read_only";
3
4
  const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
@@ -52,7 +53,9 @@ export function withDemoReadOnlyFetch(fetchHandler, demoMode) {
52
53
  ...(demoMode.allowedWritePaths ?? []),
53
54
  ]);
54
55
  return async (req, arg) => {
55
- if (MUTATING_METHODS.has(req.method) && !allowed.has(new URL(req.url).pathname)) {
56
+ // A socket carries writes after the upgrade (route `write` dep), outside this method gate.
57
+ const mutates = MUTATING_METHODS.has(req.method) || isWebSocketUpgradeRequest(req);
58
+ if (mutates && !allowed.has(new URL(req.url).pathname)) {
56
59
  return Response.json({
57
60
  isSuccess: false,
58
61
  error: {
@@ -24,6 +24,7 @@ export type BootOptionFields = {
24
24
  /** With `runSingleInstance: false`: the worker process mounts the same mail / fileStorage
25
25
  * features (its own boot options or by hand), which silences the boot warning. */
26
26
  readonly workerMountsFeatures?: boolean;
27
+ readonly appOverrides?: object;
27
28
  readonly auth?: {
28
29
  readonly admin?: unknown;
29
30
  readonly mail?: {
@@ -46,9 +46,9 @@ function wireFeatureToggles(options, mounted) {
46
46
  function warnAboutBootOptions(options) {
47
47
  const bootWarnings = stackBootWarnings(options).map((line) => `stacks in \`features\` expect boot options that are not set:\n${line}`);
48
48
  if (options.runSingleInstance === false &&
49
- (options.mail || options.fileStorage || options.featureToggles) &&
49
+ (options.mail || options.fileStorage || options.featureToggles || options.appOverrides) &&
50
50
  options.workerMountsFeatures !== true) {
51
- bootWarnings.push("runSingleInstance:false — delivery and file jobs run in runWorkerApp. Pass the same mail / fileStorage / featureToggles options there, or set workerMountsFeatures: true if it mounts those features itself.");
51
+ bootWarnings.push("runSingleInstance:false — delivery and file jobs run in runWorkerApp. Pass the same mail / fileStorage / featureToggles / appOverrides options there, or set workerMountsFeatures: true if it mounts those features itself.");
52
52
  }
53
53
  if (bootWarnings.length > 0) {
54
54
  // biome-ignore lint/suspicious/noConsole: boot-time warn, no logger configured this early
@@ -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) {
@@ -3,7 +3,7 @@ import type { RunProdAppOptions } from "../run-prod-app.ts";
3
3
  export type StackBootOptionFields = Pick<RunProdAppOptions, "features" | "kms" | "allowPlaintextPii" | BootOptionName>;
4
4
  /** Reads `bootRequires` from the raw feature list: dedupe may later drop the
5
5
  * tagged copy of a feature two stacks share. `scope` limits the check to the
6
- * options the calling boot function can set at all (runDevApp has no `signals`). */
6
+ * options the calling boot function can set at all. */
7
7
  export declare function assertStackBootRequirements(options: StackBootOptionFields, scope?: readonly BootOptionName[]): void;
8
8
  /** Requirements a stack marked `severity: "warn"`: logged at boot, never fatal. */
9
9
  export declare function stackBootWarnings(options: StackBootOptionFields): string[];