@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.
- package/dist/boot/apply-boot-seeds.js +11 -8
- package/dist/boot/job-run-logger.js +1 -1
- package/dist/boot/shared-boot-steps.d.ts +2 -2
- package/dist/boot/shared-boot-steps.js +6 -2
- package/dist/boot/stream-tenant-backfill-on-boot.d.ts +27 -0
- package/dist/boot/stream-tenant-backfill-on-boot.js +161 -0
- package/dist/boot/warn-row-level-security-off.d.ts +3 -0
- package/dist/boot/warn-row-level-security-off.js +6 -0
- package/dist/boot/worker-boot-core.d.ts +2 -1
- package/dist/boot/worker-boot-core.js +19 -2
- package/dist/boot/worker-metrics-server.js +2 -0
- package/dist/boot-options/seed-shortcuts.js +3 -1
- package/dist/compose-features.js +7 -0
- package/dist/extra-routes-deps.d.ts +1 -1
- package/dist/run-prod-app-boot-context.js +3 -5
- package/dist/run-prod-app-config.d.ts +2 -0
- package/dist/run-prod-app-config.js +2 -0
- package/dist/run-prod-app-connections.js +2 -2
- package/dist/run-prod-app-entrypoint.d.ts +1 -1
- package/dist/run-prod-app-entrypoint.js +4 -2
- package/dist/run-prod-app-handle.js +2 -0
- package/dist/run-prod-app-request-context.js +9 -2
- package/dist/run-prod-app-seeds.js +39 -26
- package/dist/run-prod-app.d.ts +11 -2
- package/dist/run-prod-app.js +5 -1
- package/dist/run-worker-app.d.ts +11 -3
- package/dist/run-worker-app.js +20 -4
- package/dist/seed-dispatcher.js +3 -1
- package/package.json +8 -8
- package/src/changes.json +31 -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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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,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
|
-
|
|
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) {
|
package/dist/compose-features.js
CHANGED
|
@@ -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
|
-
//
|
|
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,
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
}
|
package/dist/run-prod-app.d.ts
CHANGED
|
@@ -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;
|
package/dist/run-prod-app.js
CHANGED
|
@@ -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);
|
package/dist/run-worker-app.d.ts
CHANGED
|
@@ -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"]). */
|
package/dist/run-worker-app.js
CHANGED
|
@@ -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
|
|
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),
|
package/dist/seed-dispatcher.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
100
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
101
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
102
|
-
"@cosmicdrift/kumiko-renderer-mail-html": "0.
|
|
103
|
-
"@cosmicdrift/kumiko-types": "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.
|
|
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.
|
|
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",
|