@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.
- 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 +8 -5
- package/dist/boot/shared-boot-steps.js +26 -16
- 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/wait-for-database.d.ts +2 -0
- package/dist/boot/wait-for-database.js +4 -3
- 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 +28 -5
- package/dist/boot/worker-metrics-server.js +2 -0
- package/dist/boot-options/demo-mode.d.ts +3 -3
- package/dist/boot-options/demo-mode.js +4 -1
- package/dist/boot-options/resolve-boot-options.d.ts +1 -0
- package/dist/boot-options/resolve-boot-options.js +2 -2
- package/dist/boot-options/seed-shortcuts.js +3 -1
- package/dist/boot-options/stack-boot-requirements.d.ts +1 -1
- package/dist/boot-options/stack-boot-requirements.js +1 -13
- package/dist/build-prod-bundle.js +13 -13
- package/dist/bundled-lists-table.d.ts +2 -0
- package/dist/bundled-lists-table.js +56 -0
- package/dist/compose-auth-options.d.ts +16 -0
- package/dist/compose-auth-options.js +49 -0
- package/dist/compose-features.d.ts +5 -17
- package/dist/compose-features.js +13 -53
- package/dist/extra-routes-deps.d.ts +5 -1
- package/dist/extra-routes-deps.js +1 -0
- package/dist/files-for-tenant.d.ts +3 -0
- package/dist/files-for-tenant.js +10 -0
- package/dist/renderer-web-fonts.js +3 -9
- package/dist/resolve-installed-package.d.ts +10 -0
- package/dist/resolve-installed-package.js +32 -0
- package/dist/resolve-tailwind-cli.d.ts +3 -8
- package/dist/resolve-tailwind-cli.js +8 -25
- package/dist/run-prod-app-boot-context.d.ts +2 -0
- package/dist/run-prod-app-boot-context.js +11 -9
- package/dist/run-prod-app-config.d.ts +2 -0
- package/dist/run-prod-app-config.js +3 -1
- package/dist/run-prod-app-connections.d.ts +2 -1
- package/dist/run-prod-app-connections.js +11 -7
- package/dist/run-prod-app-entrypoint.d.ts +1 -1
- package/dist/run-prod-app-entrypoint.js +6 -2
- package/dist/run-prod-app-handle.js +4 -1
- package/dist/run-prod-app-request-context.js +9 -2
- package/dist/run-prod-app-seeds.js +39 -24
- package/dist/run-prod-app-static-files.js +9 -1
- package/dist/run-prod-app.d.ts +19 -4
- package/dist/run-prod-app.js +7 -3
- package/dist/run-worker-app.d.ts +22 -4
- package/dist/run-worker-app.js +22 -4
- package/dist/seed-dispatcher.js +3 -1
- package/package.json +19 -8
- 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
|
-
|
|
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,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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
"
|
|
11
|
-
"
|
|
12
|
-
"
|
|
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,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, {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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[];
|