@cosmicdrift/kumiko-server-runtime 0.388.0 → 0.389.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/shared-boot-steps.d.ts +6 -3
- package/dist/boot/shared-boot-steps.js +21 -15
- package/dist/boot/wait-for-database.d.ts +2 -0
- package/dist/boot/wait-for-database.js +4 -3
- package/dist/boot/worker-boot-core.js +9 -3
- 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/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 +6 -53
- package/dist/extra-routes-deps.d.ts +4 -0
- 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 +8 -4
- package/dist/run-prod-app-config.js +1 -1
- package/dist/run-prod-app-connections.d.ts +2 -1
- package/dist/run-prod-app-connections.js +9 -5
- package/dist/run-prod-app-entrypoint.js +2 -0
- package/dist/run-prod-app-handle.js +2 -1
- package/dist/run-prod-app-seeds.js +2 -0
- package/dist/run-prod-app-static-files.js +9 -1
- package/dist/run-prod-app.d.ts +8 -2
- package/dist/run-prod-app.js +2 -2
- package/dist/run-worker-app.d.ts +11 -1
- package/dist/run-worker-app.js +2 -0
- package/package.json +19 -8
- package/src/changes.json +61 -0
|
@@ -2,16 +2,17 @@ import { type SeedAdminOptions } from "@cosmicdrift/kumiko-bundled-features/auth
|
|
|
2
2
|
import type { AuthRoutesConfig } from "@cosmicdrift/kumiko-framework/api";
|
|
3
3
|
import type { KmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
|
|
4
4
|
import { type DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
5
|
-
import { type EffectiveFeaturesResolver, type FeatureDefinition, type Registry, type ValidateBootOptions } from "@cosmicdrift/kumiko-framework/engine";
|
|
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,13 +28,15 @@ 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>;
|
|
39
|
+
export declare function waitForKmsHealthy(kms: KmsAdapter, processName: string, deadline: number, clock?: DatabaseWaitClock): Promise<void>;
|
|
37
40
|
export declare function openDatabasePools(databaseUrl: string, envSource: Record<string, string | undefined>): {
|
|
38
41
|
readonly db: DbConnection;
|
|
39
42
|
readonly closeDb: () => Promise<void>;
|
|
@@ -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,23 @@ 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
|
+
await seedAdminGuarded(db, admin);
|
|
56
|
+
}
|
|
57
|
+
for (const seed of shortcutSeeds) {
|
|
58
|
+
await seed(db);
|
|
59
|
+
}
|
|
60
|
+
// The helper indirection is what config-seed-boot.integration.ts pins — keep it as a single call.
|
|
61
|
+
await applyBootSeeds({
|
|
62
|
+
registry,
|
|
63
|
+
db,
|
|
64
|
+
...(cipher && { cipher }),
|
|
65
|
+
});
|
|
66
|
+
}, { ...databaseWait, processName, target: "database (boot seeds)" });
|
|
62
67
|
}
|
|
63
68
|
const MFA_ROUTE_HANDLERS = {
|
|
64
69
|
mfaVerifyHandler: AuthMfaHandlers.verify,
|
|
@@ -129,8 +134,9 @@ export function buildSharedAuthRoutes(effectiveAuth, hasMfa) {
|
|
|
129
134
|
...buildAuthFlowRoutes(effectiveAuth),
|
|
130
135
|
};
|
|
131
136
|
}
|
|
132
|
-
export async function waitForKmsHealthy(kms, processName, deadline) {
|
|
137
|
+
export async function waitForKmsHealthy(kms, processName, deadline, clock) {
|
|
133
138
|
const kmsHealth = await waitUntilDatabaseReachable(() => kms.health(), {
|
|
139
|
+
...clock,
|
|
134
140
|
processName,
|
|
135
141
|
target: "KMS",
|
|
136
142
|
deadline,
|
|
@@ -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) {
|
|
@@ -67,6 +67,7 @@ export async function bootWorkerProcess(options, envSource, profile) {
|
|
|
67
67
|
const features = composeFeatures(options.features, {
|
|
68
68
|
includeBundled,
|
|
69
69
|
...(profile.authOptions && { authOptions: profile.authOptions }),
|
|
70
|
+
...(options.bundledLists && { bundledLists: options.bundledLists }),
|
|
70
71
|
});
|
|
71
72
|
const bootCrypto = resolveBootCrypto(envSource, options.masterKey);
|
|
72
73
|
validateBoot(features, {
|
|
@@ -92,9 +93,9 @@ export async function bootWorkerProcess(options, envSource, profile) {
|
|
|
92
93
|
return { kind: "dry-run", envSource };
|
|
93
94
|
}
|
|
94
95
|
// Before the connections, so an abort leaks nothing.
|
|
95
|
-
const databaseWaitDeadline = Date.now() + WAIT_FOR_DATABASE_DEADLINE_MS;
|
|
96
|
+
const databaseWaitDeadline = (options.databaseWait?.now ?? Date.now)() + WAIT_FOR_DATABASE_DEADLINE_MS;
|
|
96
97
|
if (options.kms) {
|
|
97
|
-
await waitForKmsHealthy(options.kms, processName, databaseWaitDeadline);
|
|
98
|
+
await waitForKmsHealthy(options.kms, processName, databaseWaitDeadline, options.databaseWait);
|
|
98
99
|
}
|
|
99
100
|
const { db, closeDb, outsideTransactionDb, closeOutsideTransactionDb } = openDatabasePools(databaseUrl, envSource);
|
|
100
101
|
const redis = new Redis(redisUrl, { maxRetriesPerRequest: null });
|
|
@@ -103,7 +104,11 @@ export async function bootWorkerProcess(options, envSource, profile) {
|
|
|
103
104
|
clientOptions: redisClientOptionsFromEnv(envSource),
|
|
104
105
|
});
|
|
105
106
|
try {
|
|
106
|
-
await waitForDatabase(db, {
|
|
107
|
+
await waitForDatabase(db, {
|
|
108
|
+
...options.databaseWait,
|
|
109
|
+
processName,
|
|
110
|
+
deadline: databaseWaitDeadline,
|
|
111
|
+
});
|
|
107
112
|
}
|
|
108
113
|
catch (err) {
|
|
109
114
|
await closeDb();
|
|
@@ -151,6 +156,7 @@ export async function bootWorkerProcess(options, envSource, profile) {
|
|
|
151
156
|
registry,
|
|
152
157
|
hasAuth: includeBundled,
|
|
153
158
|
redis,
|
|
159
|
+
...(options.appOverrides && { appOverrides: options.appOverrides }),
|
|
154
160
|
crypto: bootCrypto,
|
|
155
161
|
...(options.kms && { kms: options.kms }),
|
|
156
162
|
...(profile.deliverQueuedInline === true && { deliverQueuedInline: true }),
|
|
@@ -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
|
|
@@ -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[];
|
|
@@ -29,18 +29,6 @@ const OPTION_CHECKS = {
|
|
|
29
29
|
isSatisfied: (options) => options.featureToggles === true,
|
|
30
30
|
hint: "set featureToggles: true",
|
|
31
31
|
},
|
|
32
|
-
demoMode: {
|
|
33
|
-
isSatisfied: (options) => options.demoMode !== undefined,
|
|
34
|
-
hint: "set demoMode: { readOnly }",
|
|
35
|
-
},
|
|
36
|
-
seeds: {
|
|
37
|
-
isSatisfied: (options) => options.seeds !== undefined,
|
|
38
|
-
hint: "set seeds",
|
|
39
|
-
},
|
|
40
|
-
signals: {
|
|
41
|
-
isSatisfied: (options) => options.signals !== false,
|
|
42
|
-
hint: "drop signals: false",
|
|
43
|
-
},
|
|
44
32
|
rateLimit: {
|
|
45
33
|
isSatisfied: (options) => options.rateLimit !== false,
|
|
46
34
|
hint: "drop rateLimit: false",
|
|
@@ -65,7 +53,7 @@ function unsatisfiedRequirements(options, severity, scope) {
|
|
|
65
53
|
}
|
|
66
54
|
/** Reads `bootRequires` from the raw feature list: dedupe may later drop the
|
|
67
55
|
* tagged copy of a feature two stacks share. `scope` limits the check to the
|
|
68
|
-
* options the calling boot function can set at all
|
|
56
|
+
* options the calling boot function can set at all. */
|
|
69
57
|
export function assertStackBootRequirements(options, scope) {
|
|
70
58
|
const gaps = unsatisfiedRequirements(options, "error", scope);
|
|
71
59
|
if (gaps.length > 0) {
|
|
@@ -63,6 +63,7 @@ import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
|
63
63
|
import { BUNDLED_ASSETS_DIST_DIR, readBundledAssetDeclarations, resolveBundledAssetSource, } from "./bundled-assets.js";
|
|
64
64
|
import { CSP_HASHES_FILE, inlineScriptHashes } from "./csp-hashes.js";
|
|
65
65
|
import { RENDERER_WEB_FONT_FILE_PATTERN, RENDERER_WEB_FONTS_DIST_DIR, resolveRendererWebFontsDir, } from "./renderer-web-fonts.js";
|
|
66
|
+
import { resolveInstalledSync } from "./resolve-installed-package.js";
|
|
66
67
|
import { canResolveTailwindStylesheet, resolveTailwindCli } from "./resolve-tailwind-cli.js";
|
|
67
68
|
// Bun runtime check as a module-level constant: every build step (Tailwind via
|
|
68
69
|
// Bun.spawn, client bundling via Bun.build, and stylesheet resolution via
|
|
@@ -454,18 +455,11 @@ function resolveStylesheetEntry(cwd, clientEntry, override) {
|
|
|
454
455
|
// and production builds behave identically.
|
|
455
456
|
if (!clientEntry)
|
|
456
457
|
return undefined;
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
try {
|
|
460
|
-
const resolved = Bun.resolveSync("@cosmicdrift/kumiko-renderer/web/styles.css", cwd);
|
|
461
|
-
if (!canResolveTailwindStylesheet(resolved, { bun: Bun, cwd })) {
|
|
462
|
-
return undefined;
|
|
463
|
-
}
|
|
464
|
-
return { path: resolved, isRendererWebFallback: true };
|
|
465
|
-
}
|
|
466
|
-
catch {
|
|
458
|
+
const resolved = resolveInstalledSync("@cosmicdrift/kumiko-renderer/web/styles.css", cwd);
|
|
459
|
+
if (resolved === undefined || !canResolveTailwindStylesheet(resolved, { bun: Bun, cwd })) {
|
|
467
460
|
return undefined;
|
|
468
461
|
}
|
|
462
|
+
return { path: resolved, isRendererWebFallback: true };
|
|
469
463
|
}
|
|
470
464
|
// Build-time guard against unstyled production output: when the build falls
|
|
471
465
|
// back to renderer-web/styles.css but the compiled CSS lacks the shell sentinel,
|
|
@@ -579,9 +573,15 @@ function isTailwindEngine(candidate) {
|
|
|
579
573
|
// Both packages are the CLI's own dependencies, so they resolve from the CLI package directory
|
|
580
574
|
// (same version the CLI would run), not from the app root.
|
|
581
575
|
async function loadTailwindEngine(cliPath, bun) {
|
|
582
|
-
const cliDir =
|
|
583
|
-
const
|
|
584
|
-
|
|
576
|
+
const cliDir = resolve(cliPath, "..", "..");
|
|
577
|
+
const importFromCli = (packageName) => {
|
|
578
|
+
const entry = resolveInstalledSync(packageName, cliDir, bun);
|
|
579
|
+
if (entry !== undefined)
|
|
580
|
+
return import(__rewriteRelativeImportExtension(entry));
|
|
581
|
+
throw new Error(`[kumiko build] ${packageName} is not installed next to @tailwindcss/cli (${cliDir}).`);
|
|
582
|
+
};
|
|
583
|
+
const node = await importFromCli("@tailwindcss/node");
|
|
584
|
+
const oxide = await importFromCli("@tailwindcss/oxide");
|
|
585
585
|
const engine = {
|
|
586
586
|
compile: isRecord(node) ? node["compile"] : undefined,
|
|
587
587
|
optimize: isRecord(node) ? node["optimize"] : undefined,
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { normalizeListColumn, validateBundledListsOptions, } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
export function applyBundledListsTable(features, options) {
|
|
3
|
+
if (options === undefined)
|
|
4
|
+
return [...features];
|
|
5
|
+
validateBundledListsOptions(options);
|
|
6
|
+
return features.map((feature) => feature.adoptsBundledLists === true ? withListTableDefaults(feature, options) : feature);
|
|
7
|
+
}
|
|
8
|
+
function withListTableDefaults(feature, options) {
|
|
9
|
+
return {
|
|
10
|
+
...feature,
|
|
11
|
+
screens: Object.fromEntries(Object.entries(feature.screens).map(([id, screen]) => [
|
|
12
|
+
id,
|
|
13
|
+
withScreenTableDefaults(screen, options),
|
|
14
|
+
])),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function withScreenTableDefaults(screen, options) {
|
|
18
|
+
switch (screen.type) {
|
|
19
|
+
case "entityList":
|
|
20
|
+
case "projectionList": {
|
|
21
|
+
const table = { ...options.table, ...screen.table };
|
|
22
|
+
return { ...screen, table, columns: pinFirstColumn(screen.columns, table, options) };
|
|
23
|
+
}
|
|
24
|
+
case "projectionDetail":
|
|
25
|
+
case "entityEdit":
|
|
26
|
+
return {
|
|
27
|
+
...screen,
|
|
28
|
+
layout: {
|
|
29
|
+
...screen.layout,
|
|
30
|
+
sections: screen.layout.sections.map((section) => withSectionTableDefaults(section, options)),
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
default:
|
|
34
|
+
return screen;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function withSectionTableDefaults(section, options) {
|
|
38
|
+
if (section.kind !== "relatedList")
|
|
39
|
+
return section;
|
|
40
|
+
// export is only valid on entityList and projectionList
|
|
41
|
+
const { export: _export, ...defaultsWithoutExport } = options.table;
|
|
42
|
+
const table = { ...defaultsWithoutExport, ...section.table };
|
|
43
|
+
return { ...section, table, columns: pinFirstColumn(section.columns, table, options) };
|
|
44
|
+
}
|
|
45
|
+
function pinFirstColumn(columns, table, options) {
|
|
46
|
+
if (options.pinFirstColumn !== true || table.pinning !== true)
|
|
47
|
+
return columns;
|
|
48
|
+
const normalized = columns.map(normalizeListColumn);
|
|
49
|
+
if (normalized.some((column) => column.pinned !== undefined))
|
|
50
|
+
return columns;
|
|
51
|
+
// A hidden column only appears via the column picker; pinning it would surprise the user.
|
|
52
|
+
const firstVisible = normalized.findIndex((column) => column.hidden !== true);
|
|
53
|
+
if (firstVisible === -1)
|
|
54
|
+
return columns;
|
|
55
|
+
return columns.map((column, index) => index === firstVisible ? { ...normalizeListColumn(column), pinned: "left" } : column);
|
|
56
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { AccountLockoutOptions, AccountUnlockOptions, AuthEmailPasswordOptions, EmailVerificationOptions, InviteOptions, PasswordResetOptions, SignupOptions } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
|
|
2
|
+
/** Shape of any run{Prod,Dev}App auth block that can carry a
|
|
3
|
+
* passwordReset/emailVerification config, once resolveAuthMail has resolved
|
|
4
|
+
* the wrapper's optional hmacSecret into the feature API's required one —
|
|
5
|
+
* a structural-typed lookup on the auth-only subset is enough. Lets
|
|
6
|
+
* buildComposeAuthOptions be called with both RunProd- and
|
|
7
|
+
* RunDev-AuthOptions without building the helper twice. */
|
|
8
|
+
export type AuthOptionsCarrier = {
|
|
9
|
+
readonly passwordReset?: PasswordResetOptions;
|
|
10
|
+
readonly emailVerification?: EmailVerificationOptions;
|
|
11
|
+
readonly signup?: SignupOptions;
|
|
12
|
+
readonly invite?: InviteOptions;
|
|
13
|
+
readonly accountUnlock?: AccountUnlockOptions;
|
|
14
|
+
readonly accountLockout?: AccountLockoutOptions;
|
|
15
|
+
};
|
|
16
|
+
export declare function buildComposeAuthOptions(auth: AuthOptionsCarrier | undefined): AuthEmailPasswordOptions | undefined;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The magic-link mail fields shared by all five flows. Conditional spreads omit
|
|
2
|
+
// undefined keys (exactOptionalPropertyTypes) and avoid the property-write that
|
|
3
|
+
// trips noPropertyAccessFromIndexSignature on the type-aliased option shapes.
|
|
4
|
+
// reset/verify layer hmacSecret (+ mode) on top.
|
|
5
|
+
function pickMailFields(src) {
|
|
6
|
+
return {
|
|
7
|
+
appUrl: src.appUrl,
|
|
8
|
+
...(src.tokenTtlMinutes !== undefined && { tokenTtlMinutes: src.tokenTtlMinutes }),
|
|
9
|
+
...(src.appName !== undefined && { appName: src.appName }),
|
|
10
|
+
...(src.locale !== undefined && { locale: src.locale }),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
export function buildComposeAuthOptions(auth) {
|
|
14
|
+
if (!auth)
|
|
15
|
+
return undefined;
|
|
16
|
+
const opts = {};
|
|
17
|
+
if (auth.passwordReset) {
|
|
18
|
+
opts.passwordReset = {
|
|
19
|
+
hmacSecret: auth.passwordReset.hmacSecret,
|
|
20
|
+
...pickMailFields(auth.passwordReset),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
if (auth.emailVerification) {
|
|
24
|
+
opts.emailVerification = {
|
|
25
|
+
hmacSecret: auth.emailVerification.hmacSecret,
|
|
26
|
+
...pickMailFields(auth.emailVerification),
|
|
27
|
+
...(auth.emailVerification.mode !== undefined && { mode: auth.emailVerification.mode }),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
if (auth.signup) {
|
|
31
|
+
opts.signup = pickMailFields(auth.signup);
|
|
32
|
+
}
|
|
33
|
+
if (auth.invite) {
|
|
34
|
+
opts.invite = pickMailFields(auth.invite);
|
|
35
|
+
}
|
|
36
|
+
if (auth.accountUnlock) {
|
|
37
|
+
opts.accountUnlock = {
|
|
38
|
+
hmacSecret: auth.accountUnlock.hmacSecret,
|
|
39
|
+
...pickMailFields(auth.accountUnlock),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
if (auth.accountLockout) {
|
|
43
|
+
opts.accountLockout = auth.accountLockout;
|
|
44
|
+
}
|
|
45
|
+
return hasAnyAuthFlow(opts) ? opts : undefined;
|
|
46
|
+
}
|
|
47
|
+
function hasAnyAuthFlow(opts) {
|
|
48
|
+
return Object.values(opts).some(Boolean);
|
|
49
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { type
|
|
2
|
-
import { type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
1
|
+
import { type AuthEmailPasswordOptions } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
|
|
2
|
+
import { type BundledListsOptions, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
export { type AuthOptionsCarrier, buildComposeAuthOptions } from "./compose-auth-options.ts";
|
|
3
4
|
export type ComposeFeaturesOptions = {
|
|
4
5
|
/** When true, prepends config + user + tenant + auth-email-password
|
|
5
6
|
* (+ auth-self-registration when authOptions.signup is set) before the
|
|
@@ -12,20 +13,7 @@ export type ComposeFeaturesOptions = {
|
|
|
12
13
|
* auth-routes.ts but no handler dispatches). Goes hand-in-hand with the
|
|
13
14
|
* passwordReset block in RunProdAppAuthOptions / RunDevAppAuthOptions. */
|
|
14
15
|
readonly authOptions?: AuthEmailPasswordOptions;
|
|
16
|
+
/** One table block for every list screen and relatedList of the bundled features; screen-own keys win. */
|
|
17
|
+
readonly bundledLists?: BundledListsOptions;
|
|
15
18
|
};
|
|
16
19
|
export declare function composeFeatures(appFeatures: readonly FeatureDefinition[], options: ComposeFeaturesOptions): FeatureDefinition[];
|
|
17
|
-
/** Shape of any run{Prod,Dev}App auth block that can carry a
|
|
18
|
-
* passwordReset/emailVerification config, once resolveAuthMail has resolved
|
|
19
|
-
* the wrapper's optional hmacSecret into the feature API's required one —
|
|
20
|
-
* a structural-typed lookup on the auth-only subset is enough. Lets
|
|
21
|
-
* buildComposeAuthOptions be called with both RunProd- and
|
|
22
|
-
* RunDev-AuthOptions without building the helper twice. */
|
|
23
|
-
export type AuthOptionsCarrier = {
|
|
24
|
-
readonly passwordReset?: PasswordResetOptions;
|
|
25
|
-
readonly emailVerification?: EmailVerificationOptions;
|
|
26
|
-
readonly signup?: SignupOptions;
|
|
27
|
-
readonly invite?: InviteOptions;
|
|
28
|
-
readonly accountUnlock?: AccountUnlockOptions;
|
|
29
|
-
readonly accountLockout?: AccountLockoutOptions;
|
|
30
|
-
};
|
|
31
|
-
export declare function buildComposeAuthOptions(auth: AuthOptionsCarrier | undefined): AuthEmailPasswordOptions | undefined;
|
package/dist/compose-features.js
CHANGED
|
@@ -21,9 +21,11 @@ import { createTemplateResolverFeature } from "@cosmicdrift/kumiko-bundled-featu
|
|
|
21
21
|
import { collectAssignableAppRoles, createTenantFeature, } from "@cosmicdrift/kumiko-bundled-features/tenant";
|
|
22
22
|
import { TENANT_LIFECYCLE_FEATURE } from "@cosmicdrift/kumiko-bundled-features/tenant-lifecycle";
|
|
23
23
|
import { createUserFeature } from "@cosmicdrift/kumiko-bundled-features/user";
|
|
24
|
-
import { dedupeFeatures } from "@cosmicdrift/kumiko-framework/engine";
|
|
24
|
+
import { dedupeFeatures, } from "@cosmicdrift/kumiko-framework/engine";
|
|
25
25
|
import { FILES_FEATURE } from "@cosmicdrift/kumiko-framework/files";
|
|
26
26
|
import { createRendererMailHtmlFeature } from "@cosmicdrift/kumiko-renderer-mail-html";
|
|
27
|
+
import { applyBundledListsTable } from "./bundled-lists-table.js";
|
|
28
|
+
export { buildComposeAuthOptions } from "./compose-auth-options.js";
|
|
27
29
|
export function composeFeatures(appFeatures, options) {
|
|
28
30
|
// ponytail: includeBundled:false skips the auth-mfa auto-wiring below —
|
|
29
31
|
// an app composing its own foundation and mounting auth-mfa itself is
|
|
@@ -32,7 +34,7 @@ export function composeFeatures(appFeatures, options) {
|
|
|
32
34
|
// bypasses MFA. Upgrade if this trips someone: warn here when appFeatures
|
|
33
35
|
// contains AUTH_MFA_FEATURE but includeBundled is false.
|
|
34
36
|
if (!options.includeBundled)
|
|
35
|
-
return dedupeFeatures(appFeatures);
|
|
37
|
+
return applyBundledListsTable(dedupeFeatures(appFeatures), options.bundledLists);
|
|
36
38
|
// Bundled foundation goes first so its instances carry the runDevApp /
|
|
37
39
|
// runProdApp `authOptions` (passwordReset wiring etc.). App-features that
|
|
38
40
|
// ALSO declare one of these names — e.g. the create-kumiko-app picker
|
|
@@ -92,12 +94,12 @@ export function composeFeatures(appFeatures, options) {
|
|
|
92
94
|
const needsFilesTenantData = names.has(FILES_FEATURE) &&
|
|
93
95
|
names.has(TENANT_LIFECYCLE_FEATURE) &&
|
|
94
96
|
!names.has(FILES_TENANT_DATA_FEATURE);
|
|
95
|
-
return [
|
|
97
|
+
return applyBundledListsTable([
|
|
96
98
|
...bundled,
|
|
97
99
|
...missingAuthMailStack(names),
|
|
98
100
|
...filteredApp,
|
|
99
101
|
...(needsFilesTenantData ? [createFilesTenantDataFeature()] : []),
|
|
100
|
-
];
|
|
102
|
+
], options.bundledLists);
|
|
101
103
|
}
|
|
102
104
|
// auth-email-password declares its mails as stored templates, and the boot seed
|
|
103
105
|
// fails without the template-resolver and a mail renderer. Mounting the missing
|
|
@@ -110,52 +112,3 @@ function missingAuthMailStack(mountedNames) {
|
|
|
110
112
|
createRendererMailHtmlFeature(),
|
|
111
113
|
].filter((feature) => !mountedNames.has(feature.name));
|
|
112
114
|
}
|
|
113
|
-
// The magic-link mail fields shared by all five flows. Conditional spreads omit
|
|
114
|
-
// undefined keys (exactOptionalPropertyTypes) and avoid the property-write that
|
|
115
|
-
// trips noPropertyAccessFromIndexSignature on the type-aliased option shapes.
|
|
116
|
-
// reset/verify layer hmacSecret (+ mode) on top.
|
|
117
|
-
function pickMailFields(src) {
|
|
118
|
-
return {
|
|
119
|
-
appUrl: src.appUrl,
|
|
120
|
-
...(src.tokenTtlMinutes !== undefined && { tokenTtlMinutes: src.tokenTtlMinutes }),
|
|
121
|
-
...(src.appName !== undefined && { appName: src.appName }),
|
|
122
|
-
...(src.locale !== undefined && { locale: src.locale }),
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
export function buildComposeAuthOptions(auth) {
|
|
126
|
-
if (!auth)
|
|
127
|
-
return undefined;
|
|
128
|
-
const opts = {};
|
|
129
|
-
if (auth.passwordReset) {
|
|
130
|
-
opts.passwordReset = {
|
|
131
|
-
hmacSecret: auth.passwordReset.hmacSecret,
|
|
132
|
-
...pickMailFields(auth.passwordReset),
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
if (auth.emailVerification) {
|
|
136
|
-
opts.emailVerification = {
|
|
137
|
-
hmacSecret: auth.emailVerification.hmacSecret,
|
|
138
|
-
...pickMailFields(auth.emailVerification),
|
|
139
|
-
...(auth.emailVerification.mode !== undefined && { mode: auth.emailVerification.mode }),
|
|
140
|
-
};
|
|
141
|
-
}
|
|
142
|
-
if (auth.signup) {
|
|
143
|
-
opts.signup = pickMailFields(auth.signup);
|
|
144
|
-
}
|
|
145
|
-
if (auth.invite) {
|
|
146
|
-
opts.invite = pickMailFields(auth.invite);
|
|
147
|
-
}
|
|
148
|
-
if (auth.accountUnlock) {
|
|
149
|
-
opts.accountUnlock = {
|
|
150
|
-
hmacSecret: auth.accountUnlock.hmacSecret,
|
|
151
|
-
...pickMailFields(auth.accountUnlock),
|
|
152
|
-
};
|
|
153
|
-
}
|
|
154
|
-
if (auth.accountLockout) {
|
|
155
|
-
opts.accountLockout = auth.accountLockout;
|
|
156
|
-
}
|
|
157
|
-
return hasAnyAuthFlow(opts) ? opts : undefined;
|
|
158
|
-
}
|
|
159
|
-
function hasAnyAuthFlow(opts) {
|
|
160
|
-
return Object.values(opts).some(Boolean);
|
|
161
|
-
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import type { SystemDispatchArgs } from "@cosmicdrift/kumiko-framework/api";
|
|
2
2
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import type { Registry, WriteResult } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import type { FileContext } from "@cosmicdrift/kumiko-framework/files";
|
|
4
5
|
import type { MetricsHandle } from "@cosmicdrift/kumiko-framework/observability";
|
|
5
6
|
import type { EnvelopeCipher, MasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
|
|
6
7
|
import type { Redis } from "ioredis";
|
|
7
8
|
export type { SystemDispatchArgs } from "@cosmicdrift/kumiko-framework/api";
|
|
8
9
|
export { makeDispatchSystemQuery, makeDispatchSystemWrite, } from "@cosmicdrift/kumiko-framework/api";
|
|
10
|
+
export { makeFilesForTenant } from "./files-for-tenant.ts";
|
|
9
11
|
/** Deps for the `wire` hook (runProdApp/createKumikoServer, after
|
|
10
12
|
* buildServer has mounted `extraRoutes`) and
|
|
11
13
|
* for `runWorkerApp.wireComponents`. Naming: `deps` not `ctx` because
|
|
@@ -18,6 +20,8 @@ export type SystemWireDeps = {
|
|
|
18
20
|
/** Feature-registry — e.g. for plugin-lookups via
|
|
19
21
|
* `registry.getExtensionUsages("subscriptionProvider")`. */
|
|
20
22
|
readonly registry: Registry;
|
|
23
|
+
/** File context of a tenant's storage; undefined when no file storage is mounted. Required so a consumer cannot lose attachment reading by omission. */
|
|
24
|
+
readonly filesForTenant: (tenantId: string) => FileContext | undefined;
|
|
21
25
|
/** The boot's master key keyring; absent when no `KUMIKO_SECRETS_MASTER_KEY_V<n>` is set. */
|
|
22
26
|
readonly masterKeyProvider?: MasterKeyProvider;
|
|
23
27
|
/** Cipher for `encrypted: true` config keys, same instance and DEK cache as the handlers use. */
|
|
@@ -2,3 +2,4 @@
|
|
|
2
2
|
// truth lives in framework/api next to buildServer's own `extraRoutes`
|
|
3
3
|
// mount so the two never drift on how the SystemAdmin identity is built.
|
|
4
4
|
export { makeDispatchSystemQuery, makeDispatchSystemWrite, } from "@cosmicdrift/kumiko-framework/api";
|
|
5
|
+
export { makeFilesForTenant } from "./files-for-tenant.js";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { fileContextFromResolver } from "@cosmicdrift/kumiko-bundled-features/file-foundation";
|
|
2
|
+
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
// Takes the AppContext buildServer returned: only that one carries `_fileProviderResolver`.
|
|
4
|
+
// Without a resolver, `context.files` is bound to the system tenant and reaches no other one.
|
|
5
|
+
export function makeFilesForTenant(context) {
|
|
6
|
+
const resolver = context._fileProviderResolver;
|
|
7
|
+
if (resolver)
|
|
8
|
+
return (tenantId) => fileContextFromResolver(resolver, tenantId);
|
|
9
|
+
return (tenantId) => (tenantId === SYSTEM_TENANT_ID ? context.files : undefined);
|
|
10
|
+
}
|
|
@@ -3,26 +3,20 @@
|
|
|
3
3
|
// dir (dev) or hashed into dist/assets (prod), away from the source fonts.
|
|
4
4
|
// data: URIs are out because apps run with `font-src 'self'`.
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
|
+
import { resolveInstalledSync } from "./resolve-installed-package.js";
|
|
6
7
|
export const RENDERER_WEB_FONTS_URL_PREFIX = "/assets/kumiko/fonts/";
|
|
7
8
|
/** dist-relative folder the prod build copies the fonts into. */
|
|
8
9
|
export const RENDERER_WEB_FONTS_DIST_DIR = "assets/kumiko/fonts";
|
|
9
10
|
/** Allowlist for servable font file names; also rules out path segments. */
|
|
10
11
|
export const RENDERER_WEB_FONT_FILE_PATTERN = /^[a-z0-9-]+\.woff2$/;
|
|
11
|
-
const hasBun = typeof Bun !== "undefined";
|
|
12
12
|
/** Directory of the woff2 files next to renderer-web's styles.css, or
|
|
13
13
|
* undefined when the package can't be resolved from any `searchFrom` dir
|
|
14
14
|
* (or outside Bun, which does the package-exports resolution). */
|
|
15
15
|
export function resolveRendererWebFontsDir(searchFrom) {
|
|
16
|
-
if (!hasBun)
|
|
17
|
-
return undefined;
|
|
18
16
|
for (const from of searchFrom) {
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
const stylesheet = resolveInstalledSync("@cosmicdrift/kumiko-renderer/web/styles.css", from);
|
|
18
|
+
if (stylesheet !== undefined)
|
|
21
19
|
return join(dirname(stylesheet), "fonts");
|
|
22
|
-
}
|
|
23
|
-
catch {
|
|
24
|
-
// try the next search root
|
|
25
|
-
}
|
|
26
20
|
}
|
|
27
21
|
return undefined;
|
|
28
22
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type BunResolver = {
|
|
2
|
+
readonly resolveSync: (id: string, from: string) => string;
|
|
3
|
+
};
|
|
4
|
+
export declare function packageNameOf(specifier: string): string;
|
|
5
|
+
/** Walks node_modules upwards like Node does; returns the real path so transitive deps
|
|
6
|
+
* of an isolated-linker install are found as siblings. */
|
|
7
|
+
export declare function findInstalledPackageDir(packageName: string, fromDir: string): string | undefined;
|
|
8
|
+
/** Bun.resolveSync auto-installs from the registry (since 1.4.4) when no node_modules tree
|
|
9
|
+
* exists, so only call it for packages that are already installed. */
|
|
10
|
+
export declare function resolveInstalledSync(specifier: string, fromDir: string, bun?: BunResolver | undefined): string | undefined;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
export function packageNameOf(specifier) {
|
|
4
|
+
const segments = specifier.split("/");
|
|
5
|
+
return specifier.startsWith("@") ? segments.slice(0, 2).join("/") : (segments[0] ?? specifier);
|
|
6
|
+
}
|
|
7
|
+
/** Walks node_modules upwards like Node does; returns the real path so transitive deps
|
|
8
|
+
* of an isolated-linker install are found as siblings. */
|
|
9
|
+
export function findInstalledPackageDir(packageName, fromDir) {
|
|
10
|
+
for (let dir = resolve(fromDir);; dir = resolve(dir, "..")) {
|
|
11
|
+
const packageDir = join(dir, "node_modules", packageName);
|
|
12
|
+
if (existsSync(join(packageDir, "package.json")))
|
|
13
|
+
return realpathSync(packageDir);
|
|
14
|
+
if (resolve(dir, "..") === dir)
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const globalBun = typeof Bun !== "undefined" ? Bun : undefined;
|
|
19
|
+
/** Bun.resolveSync auto-installs from the registry (since 1.4.4) when no node_modules tree
|
|
20
|
+
* exists, so only call it for packages that are already installed. */
|
|
21
|
+
export function resolveInstalledSync(specifier, fromDir, bun = globalBun) {
|
|
22
|
+
if (bun === undefined)
|
|
23
|
+
return undefined;
|
|
24
|
+
if (findInstalledPackageDir(packageNameOf(specifier), fromDir) === undefined)
|
|
25
|
+
return undefined;
|
|
26
|
+
try {
|
|
27
|
+
return bun.resolveSync(specifier, fromDir);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -1,14 +1,9 @@
|
|
|
1
|
-
type BunResolver
|
|
2
|
-
resolveSync: (id: string, from: string) => string;
|
|
3
|
-
};
|
|
1
|
+
import { type BunResolver } from "./resolve-installed-package.ts";
|
|
4
2
|
export type ResolveTailwindCliDeps = {
|
|
5
3
|
readonly bun?: BunResolver;
|
|
6
4
|
readonly cwd: string;
|
|
7
5
|
};
|
|
8
6
|
export declare function resolveTailwindCli(deps: ResolveTailwindCliDeps): string | undefined;
|
|
9
|
-
/** Tailwind v4 resolves `@import "tailwindcss"` relative to the entry
|
|
10
|
-
*
|
|
11
|
-
* aren't resolvable from that directory — e.g. Bun cache copies of
|
|
12
|
-
* `@cosmicdrift/kumiko-renderer/web` outside a hoisted node_modules tree. */
|
|
7
|
+
/** Tailwind v4 resolves `@import "tailwindcss"` relative to the entry CSS file, so
|
|
8
|
+
* tailwindcss must be installed above it (not just next to a Bun cache copy of renderer-web). */
|
|
13
9
|
export declare function canResolveTailwindStylesheet(entryCss: string, deps: ResolveTailwindCliDeps): boolean;
|
|
14
|
-
export {};
|
|
@@ -1,34 +1,17 @@
|
|
|
1
1
|
// Resolves the locally installed @tailwindcss/cli binary to its absolute path.
|
|
2
2
|
// We avoid `bunx` because it still requests the registry manifest for a locally
|
|
3
3
|
// installed package and fails with `FailedToOpenSocket` when offline.
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
// branches can be tested under Vitest/Node without booting the server.
|
|
7
|
-
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { findInstalledPackageDir } from "./resolve-installed-package.js";
|
|
8
6
|
export function resolveTailwindCli(deps) {
|
|
9
7
|
if (deps.bun === undefined)
|
|
10
8
|
return undefined;
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
// bin: { tailwindcss: "./dist/index.mjs" } → absoluter Pfad
|
|
14
|
-
return resolve(pkgJsonPath, "..", "dist", "index.mjs");
|
|
15
|
-
}
|
|
16
|
-
catch {
|
|
17
|
-
return undefined;
|
|
18
|
-
}
|
|
9
|
+
const cliDir = findInstalledPackageDir("@tailwindcss/cli", deps.cwd);
|
|
10
|
+
return cliDir === undefined ? undefined : join(cliDir, "dist", "index.mjs");
|
|
19
11
|
}
|
|
20
|
-
/** Tailwind v4 resolves `@import "tailwindcss"` relative to the entry
|
|
21
|
-
*
|
|
22
|
-
* aren't resolvable from that directory — e.g. Bun cache copies of
|
|
23
|
-
* `@cosmicdrift/kumiko-renderer/web` outside a hoisted node_modules tree. */
|
|
12
|
+
/** Tailwind v4 resolves `@import "tailwindcss"` relative to the entry CSS file, so
|
|
13
|
+
* tailwindcss must be installed above it (not just next to a Bun cache copy of renderer-web). */
|
|
24
14
|
export function canResolveTailwindStylesheet(entryCss, deps) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
try {
|
|
28
|
-
deps.bun.resolveSync("tailwindcss", dirname(entryCss));
|
|
29
|
-
return true;
|
|
30
|
-
}
|
|
31
|
-
catch {
|
|
32
|
-
return false;
|
|
33
|
-
}
|
|
15
|
+
return (deps.bun !== undefined &&
|
|
16
|
+
findInstalledPackageDir("tailwindcss", dirname(entryCss)) !== undefined);
|
|
34
17
|
}
|
|
@@ -16,6 +16,8 @@ export declare function addConfigAccessorFactory<T extends {
|
|
|
16
16
|
export declare function loggerFromExtraContext(extraContext: Record<string, unknown>): Logger | undefined;
|
|
17
17
|
/** Config key (`feature:config:name`) to value; wins over the key's env variable. */
|
|
18
18
|
export type ConfigOverridesInput = Readonly<Record<string, string | number | boolean>>;
|
|
19
|
+
/** Env bridge first, validated app overrides on top: the one merge every boot function shares. */
|
|
20
|
+
export declare function buildConfigOverrides(registry: Registry, envSource: Record<string, string | undefined>, appOverrides: ConfigOverridesInput | undefined): import("@cosmicdrift/kumiko-bundled-features/config").AppConfigOverrides;
|
|
19
21
|
type BootExtraContextOptions = {
|
|
20
22
|
readonly db: DbConnection;
|
|
21
23
|
readonly features: readonly FeatureDefinition[];
|
|
@@ -120,12 +120,16 @@ function bootCryptoContext(crypto) {
|
|
|
120
120
|
...(crypto.configCipher && { configEncryption: crypto.configCipher }),
|
|
121
121
|
};
|
|
122
122
|
}
|
|
123
|
-
|
|
123
|
+
/** Env bridge first, validated app overrides on top: the one merge every boot function shares. */
|
|
124
|
+
export function buildConfigOverrides(registry, envSource, appOverrides) {
|
|
124
125
|
const envOverrides = buildEnvConfigOverrides(registry, envSource);
|
|
126
|
+
return appOverrides
|
|
127
|
+
? new Map([...envOverrides, ...validateAppOverrides(registry, appOverrides)])
|
|
128
|
+
: envOverrides;
|
|
129
|
+
}
|
|
130
|
+
function createBootConfigResolver(registry, envSource, crypto, appOverrides) {
|
|
125
131
|
return createConfigResolver({
|
|
126
|
-
appOverrides: appOverrides
|
|
127
|
-
? new Map([...envOverrides, ...validateAppOverrides(registry, appOverrides)])
|
|
128
|
-
: envOverrides,
|
|
132
|
+
appOverrides: buildConfigOverrides(registry, envSource, appOverrides),
|
|
129
133
|
...(crypto.configCipher && { cipher: crypto.configCipher }),
|
|
130
134
|
});
|
|
131
135
|
}
|
|
@@ -136,7 +136,7 @@ export function composeProdFeatures(options, config, log) {
|
|
|
136
136
|
// auth-email-password via composeFeatures, using the same feature list as
|
|
137
137
|
// runDevApp and the per-app schema generator. Build it before any connection
|
|
138
138
|
// so boot mode can validate wiring without opening Postgres or Redis sockets.
|
|
139
|
-
const features = composeAppFeatures(options.features, bootOptionWiring.features, effectiveAuth);
|
|
139
|
+
const features = composeAppFeatures(options.features, bootOptionWiring.features, effectiveAuth, options.bundledLists);
|
|
140
140
|
const bootCrypto = resolveBootCrypto(envSource, options.masterKey);
|
|
141
141
|
validateComposedFeatures(features, {
|
|
142
142
|
env: envSource,
|
|
@@ -2,6 +2,7 @@ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
|
2
2
|
import type { Logger } from "@cosmicdrift/kumiko-framework/logging";
|
|
3
3
|
import { createRedisCacheSyncBus } from "@cosmicdrift/kumiko-framework/redis";
|
|
4
4
|
import { Redis } from "ioredis";
|
|
5
|
+
import { type DatabaseWaitClock } from "./boot/wait-for-database.ts";
|
|
5
6
|
import type { RunProdAppOptions } from "./run-prod-app.ts";
|
|
6
7
|
import type { ProdBootConfig, RegisterBootAbortCleanup } from "./run-prod-app-config.ts";
|
|
7
8
|
export type ProdConnections = {
|
|
@@ -12,5 +13,5 @@ export type ProdConnections = {
|
|
|
12
13
|
readonly redis: Redis;
|
|
13
14
|
readonly cacheSync: ReturnType<typeof createRedisCacheSyncBus>;
|
|
14
15
|
};
|
|
15
|
-
export declare function connectProdDependencies(config: ProdBootConfig, log: Logger, registerAbortCleanup: RegisterBootAbortCleanup): Promise<ProdConnections>;
|
|
16
|
+
export declare function connectProdDependencies(config: ProdBootConfig, log: Logger, registerAbortCleanup: RegisterBootAbortCleanup, databaseWait?: DatabaseWaitClock): Promise<ProdConnections>;
|
|
16
17
|
export declare function assertProdSchemaCurrent(db: DbConnection, migrations: RunProdAppOptions["migrations"], log: Logger): Promise<void>;
|
|
@@ -2,19 +2,19 @@ import { assertKumikoSchemaCurrent, SchemaDriftError, } from "@cosmicdrift/kumik
|
|
|
2
2
|
import { createRedisCacheSyncBus, logRedisConnectionState, redisClientOptionsFromEnv, } from "@cosmicdrift/kumiko-framework/redis";
|
|
3
3
|
import { Redis } from "ioredis";
|
|
4
4
|
import { openDatabasePools, waitForKmsHealthy } from "./boot/shared-boot-steps.js";
|
|
5
|
-
import { WAIT_FOR_DATABASE_DEADLINE_MS, waitForDatabase } from "./boot/wait-for-database.js";
|
|
5
|
+
import { WAIT_FOR_DATABASE_DEADLINE_MS, waitForDatabase, } from "./boot/wait-for-database.js";
|
|
6
6
|
// Postgres + Redis. The Redis client is shared by idempotency, event-dedup,
|
|
7
7
|
// entity-cache, rate-limit; failing to construct here surfaces the misconfig
|
|
8
8
|
// immediately. `new Redis(...)` connects eagerly, so this must stay AFTER the
|
|
9
9
|
// boot-mode exit.
|
|
10
|
-
export async function connectProdDependencies(config, log, registerAbortCleanup) {
|
|
10
|
+
export async function connectProdDependencies(config, log, registerAbortCleanup, databaseWait) {
|
|
11
11
|
const { bootKms, databaseUrl, redisUrl, envSource } = config;
|
|
12
12
|
// KMS health gate: an app configured for crypto-shredding must not boot
|
|
13
13
|
// against an unreachable key store — a silent start would answer every
|
|
14
14
|
// PII read with 503. Runs BEFORE connections so an abort leaks nothing.
|
|
15
|
-
const databaseWaitDeadline = Date.now() + WAIT_FOR_DATABASE_DEADLINE_MS;
|
|
15
|
+
const databaseWaitDeadline = (databaseWait?.now ?? Date.now)() + WAIT_FOR_DATABASE_DEADLINE_MS;
|
|
16
16
|
if (bootKms.kms) {
|
|
17
|
-
await waitForKmsHealthy(bootKms.kms, "runProdApp", databaseWaitDeadline);
|
|
17
|
+
await waitForKmsHealthy(bootKms.kms, "runProdApp", databaseWaitDeadline, databaseWait);
|
|
18
18
|
}
|
|
19
19
|
const pools = openDatabasePools(databaseUrl, envSource);
|
|
20
20
|
const redis = new Redis(redisUrl, { maxRetriesPerRequest: null });
|
|
@@ -22,7 +22,11 @@ export async function connectProdDependencies(config, log, registerAbortCleanup)
|
|
|
22
22
|
registerAbortCleanup(pools.closeOutsideTransactionDb);
|
|
23
23
|
registerAbortCleanup(() => redis.disconnect());
|
|
24
24
|
logRedisConnectionState(redis, log);
|
|
25
|
-
await waitForDatabase(pools.db, {
|
|
25
|
+
await waitForDatabase(pools.db, {
|
|
26
|
+
...databaseWait,
|
|
27
|
+
processName: "runProdApp",
|
|
28
|
+
deadline: databaseWaitDeadline,
|
|
29
|
+
});
|
|
26
30
|
// One bus per process: the tier resolver, the dispatcher and the file-provider
|
|
27
31
|
// resolver all subscribe to it (invalidation across the API pods).
|
|
28
32
|
const cacheSync = createRedisCacheSyncBus({
|
|
@@ -4,6 +4,7 @@ import { jobRunLoggerCallbacks } from "./boot/job-run-logger.js";
|
|
|
4
4
|
import { startPiiEventBackfillOnBoot } from "./boot/pii-event-backfill-on-boot.js";
|
|
5
5
|
import { recordAppStartedOnBoot } from "./boot/record-app-started-on-boot.js";
|
|
6
6
|
import { makeDispatchSystemQuery, makeDispatchSystemWrite } from "./extra-routes-deps.js";
|
|
7
|
+
import { makeFilesForTenant } from "./files-for-tenant.js";
|
|
7
8
|
import { buildProdAuthRoutes } from "./run-prod-app-auth.js";
|
|
8
9
|
function collectProdExtraRoutes(options, config, composed) {
|
|
9
10
|
const bootExtraRoutes = config.bootOptionWiring.extraRoutes;
|
|
@@ -126,6 +127,7 @@ export async function runProdWireHook(options, composed, connections, entrypoint
|
|
|
126
127
|
db: connections.db,
|
|
127
128
|
redis: connections.redis,
|
|
128
129
|
registry: composed.registry,
|
|
130
|
+
filesForTenant: makeFilesForTenant(entrypoint.context),
|
|
129
131
|
...(bootCrypto.masterKeyProvider && { masterKeyProvider: bootCrypto.masterKeyProvider }),
|
|
130
132
|
...(bootCrypto.configCipher && { configEncryption: bootCrypto.configCipher }),
|
|
131
133
|
metricsFor: createMetricsForFactory(entrypoint.observability.meter),
|
|
@@ -45,7 +45,8 @@ export function createProdAppHandle(input) {
|
|
|
45
45
|
const { options, config, composed, connections, entrypoint, log } = input;
|
|
46
46
|
const { fetchHandler, cspScriptHashes } = input.fetch;
|
|
47
47
|
// Upgrade rejections (401/403/426...) are ordinary responses and get the same headers as fetchHandler.
|
|
48
|
-
|
|
48
|
+
// Same read-only demo gate as fetchHandler: upgrade requests are GETs, but the socket then writes.
|
|
49
|
+
const securedUpgradeFetch = withSecurityHeaders(config.bootOptionWiring.wrapFetch(async (req, env) => stripNoRouteMatchHeader(await entrypoint.app.fetch(req, env))), options.securityHeaders, cspScriptHashes);
|
|
49
50
|
const webSocketUpgradeFetch = async (req, env) => securedUpgradeFetch(req, env);
|
|
50
51
|
let uninstallShutdownSignals;
|
|
51
52
|
let uninstallFatalHandlers;
|
|
@@ -65,6 +65,8 @@ export async function runProdBootSeeds(run, entrypoint, dispatcherOptions) {
|
|
|
65
65
|
admin: composed.effectiveAuth?.admin,
|
|
66
66
|
shortcutSeeds: config.bootOptionWiring.shortcutSeeds,
|
|
67
67
|
cipher: composed.bootCrypto.configCipher,
|
|
68
|
+
processName: "runProdApp",
|
|
69
|
+
...(options.databaseWait && { databaseWait: options.databaseWait }),
|
|
68
70
|
});
|
|
69
71
|
const hasSeeds = config.bootOptionWiring.customSeeds.length > 0 || options.seedsDir !== undefined;
|
|
70
72
|
const seedContext = hasSeeds ? buildSeedContext(run) : undefined;
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
1
2
|
import { join, resolve, sep } from "node:path";
|
|
2
3
|
import { cachedResponse, computeStrongEtag, computeWeakEtag, createClientIpResolver, } from "@cosmicdrift/kumiko-framework/api";
|
|
3
4
|
import { rankPreferredLocales } from "@cosmicdrift/kumiko-framework/i18n";
|
|
4
5
|
import { resolveAndInjectPageHead } from "@cosmicdrift/kumiko-renderer/headless/apex";
|
|
5
6
|
import { errorCodeOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
6
|
-
import { ASSETS_DIR } from "./build-prod-bundle.js";
|
|
7
|
+
import { ASSETS_DIR, collectBuildScriptHashes } from "./build-prod-bundle.js";
|
|
7
8
|
import { BUNDLED_ASSETS_DIST_DIR } from "./bundled-assets.js";
|
|
8
9
|
import { CSP_HASHES_FILE, parseCspHashes } from "./csp-hashes.js";
|
|
9
10
|
import { buildRequestBoundSystemQuery, } from "./request-bound-system-query.js";
|
|
@@ -67,7 +68,14 @@ function usesDefaultCsp(securityHeaders) {
|
|
|
67
68
|
export async function resolveHostDispatchScriptHashes(staticDir, securityHeaders, defaultCspHashes) {
|
|
68
69
|
return usesDefaultCsp(securityHeaders) ? defaultCspHashes : readBuildScriptHashes(staticDir);
|
|
69
70
|
}
|
|
71
|
+
// Apps also write HTML into staticDir at boot (rendered landing pages), after kumiko build wrote
|
|
72
|
+
// the file; staticDir is server-owned, so hashing what is on disk never trusts request data.
|
|
70
73
|
export async function readBuildScriptHashes(staticDir) {
|
|
74
|
+
const fromFile = await readBuildHashesFile(staticDir);
|
|
75
|
+
const onDisk = existsSync(staticDir) ? collectBuildScriptHashes(staticDir).scriptSrc : [];
|
|
76
|
+
return [...new Set([...fromFile, ...onDisk])].sort();
|
|
77
|
+
}
|
|
78
|
+
async function readBuildHashesFile(staticDir) {
|
|
71
79
|
const file = await readStaticFile(join(staticDir, CSP_HASHES_FILE));
|
|
72
80
|
const result = file ? parseCspHashes(new TextDecoder().decode(file.bytes)) : undefined;
|
|
73
81
|
if (result?.kind === "found")
|
package/dist/run-prod-app.d.ts
CHANGED
|
@@ -3,7 +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 { FeatureDefinition, ValidateBootOptions } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
+
import type { BundledListsOptions, FeatureDefinition, ValidateBootOptions } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
7
|
import type { AllInOneEntrypoint, ApiEntrypoint } from "@cosmicdrift/kumiko-framework/entrypoint";
|
|
8
8
|
import type { ComposedEnvSchema, KumikoBootError } from "@cosmicdrift/kumiko-framework/env";
|
|
9
9
|
import type { ErrorDocsConfig } from "@cosmicdrift/kumiko-framework/errors";
|
|
@@ -12,6 +12,7 @@ import type { ObservabilityProvider } from "@cosmicdrift/kumiko-framework/observ
|
|
|
12
12
|
import type { MasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
|
|
13
13
|
import type { PageHeadMeta, PageHeadResolver, PageHeadSystemQuery } from "@cosmicdrift/kumiko-renderer/headless/apex";
|
|
14
14
|
import type { Redis } from "ioredis";
|
|
15
|
+
import type { DatabaseWaitClock } from "./boot/wait-for-database.ts";
|
|
15
16
|
import type { BillingBootOption } from "./boot-options/billing-option.ts";
|
|
16
17
|
import type { DemoModeOption } from "./boot-options/demo-mode.ts";
|
|
17
18
|
import type { FileStorageBootOption } from "./boot-options/file-storage-option.ts";
|
|
@@ -29,7 +30,7 @@ export { parseRunMode } from "./boot/env-gate.ts";
|
|
|
29
30
|
export { buildBunServeOptions } from "./bun-serve-options.ts";
|
|
30
31
|
export { requireEnv } from "./require-env.ts";
|
|
31
32
|
export { resolveUnsubscribeWiring, type UnsubscribeOption, } from "./resolve-unsubscribe-wiring.ts";
|
|
32
|
-
export { addConfigAccessorFactory, buildBootExtraContext, type ConfigOverridesInput, loggerFromExtraContext, resolveAuthMail, } from "./run-prod-app-boot-context.ts";
|
|
33
|
+
export { addConfigAccessorFactory, buildBootExtraContext, buildConfigOverrides, type ConfigOverridesInput, loggerFromExtraContext, resolveAuthMail, } from "./run-prod-app-boot-context.ts";
|
|
33
34
|
export { staticCachePolicy } from "./run-prod-app-static-files.ts";
|
|
34
35
|
/** Wrapper API for the password-reset flow.
|
|
35
36
|
*
|
|
@@ -263,6 +264,8 @@ export type RunProdAppOptions = {
|
|
|
263
264
|
readonly features: readonly FeatureDefinition[];
|
|
264
265
|
/** Opt-in boot-validator warnings — see ValidateBootOptions. */
|
|
265
266
|
readonly validateBootOptions?: ValidateBootOptions;
|
|
267
|
+
/** One table block for every list of the bundled features, see ComposeFeaturesOptions.bundledLists. */
|
|
268
|
+
readonly bundledLists?: BundledListsOptions;
|
|
266
269
|
/** Listen-Port. Default 3000 (or $PORT). */
|
|
267
270
|
readonly port?: number;
|
|
268
271
|
/** Ceiling Bun.serve buffers a request body to before any app-level
|
|
@@ -349,6 +352,9 @@ export type RunProdAppOptions = {
|
|
|
349
352
|
* cannot start against an unreachable KMS. Without an adapter,
|
|
350
353
|
* crypto-shredding remains disabled. */
|
|
351
354
|
readonly kms?: KmsAdapter;
|
|
355
|
+
/** Replaces the real clock, sleep and log sink of the boot backoff for the database, KMS and
|
|
356
|
+
* seed waits; tests use it to run the retry path without real waiting. */
|
|
357
|
+
readonly databaseWait?: DatabaseWaitClock;
|
|
352
358
|
/** 32-byte base64 key for blind-index HMACs on `lookupable` fields
|
|
353
359
|
* (env: KUMIKO_BLIND_INDEX_KEY, `openssl rand -base64 32`). It is separate
|
|
354
360
|
* from PLATFORM_KEK and not part of the KmsAdapter contract. It is required
|
package/dist/run-prod-app.js
CHANGED
|
@@ -54,7 +54,7 @@ export { parseRunMode } from "./boot/env-gate.js";
|
|
|
54
54
|
export { buildBunServeOptions } from "./bun-serve-options.js";
|
|
55
55
|
export { requireEnv } from "./require-env.js";
|
|
56
56
|
export { resolveUnsubscribeWiring, } from "./resolve-unsubscribe-wiring.js";
|
|
57
|
-
export { addConfigAccessorFactory, buildBootExtraContext, loggerFromExtraContext, resolveAuthMail, } from "./run-prod-app-boot-context.js";
|
|
57
|
+
export { addConfigAccessorFactory, buildBootExtraContext, buildConfigOverrides, loggerFromExtraContext, resolveAuthMail, } from "./run-prod-app-boot-context.js";
|
|
58
58
|
export { staticCachePolicy } from "./run-prod-app-static-files.js";
|
|
59
59
|
// Returned from runProdApp when KUMIKO_DRY_RUN_ENV is set AND envSource
|
|
60
60
|
// was passed (= test-mode). The handle is intentionally inert — listen()
|
|
@@ -119,7 +119,7 @@ async function bootProdApp(options, registerAbortCleanup) {
|
|
|
119
119
|
}
|
|
120
120
|
return makeDryRunHandle();
|
|
121
121
|
}
|
|
122
|
-
const connections = await connectProdDependencies(config, log, registerAbortCleanup);
|
|
122
|
+
const connections = await connectProdDependencies(config, log, registerAbortCleanup, options.databaseWait);
|
|
123
123
|
// Tier-composition auto-wire: if a feature carries a tenantTierResolver extension, the
|
|
124
124
|
// resolver is built here, before the dispatcher exists. The app author only mounts
|
|
125
125
|
// `createTierEngineFeature(opts)`; the framework does the wiring.
|
package/dist/run-worker-app.d.ts
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
import type { ServerOptions } from "@cosmicdrift/kumiko-framework/api";
|
|
2
2
|
import type { KmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
|
|
3
3
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
4
|
-
import type { FeatureDefinition, Registry, ValidateBootOptions } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import type { BundledListsOptions, FeatureDefinition, Registry, ValidateBootOptions } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
5
|
import type { WorkerEntrypoint } from "@cosmicdrift/kumiko-framework/entrypoint";
|
|
6
6
|
import type { ComposedEnvSchema, KumikoBootError } from "@cosmicdrift/kumiko-framework/env";
|
|
7
7
|
import type { JobRunnerOptions } from "@cosmicdrift/kumiko-framework/jobs";
|
|
8
8
|
import { type ObservabilityProvider } from "@cosmicdrift/kumiko-framework/observability";
|
|
9
9
|
import type { MasterKeyProvider, SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
10
10
|
import type { Redis } from "ioredis";
|
|
11
|
+
import type { DatabaseWaitClock } from "./boot/wait-for-database.ts";
|
|
11
12
|
import { type WorkerMetricsOptions, type WorkerMetricsServer } from "./boot/worker-metrics-server.ts";
|
|
12
13
|
import type { BillingBootOption } from "./boot-options/billing-option.ts";
|
|
13
14
|
import type { FileStorageBootOption } from "./boot-options/file-storage-option.ts";
|
|
14
15
|
import type { KmsMode } from "./boot-options/kms-mode.ts";
|
|
15
16
|
import type { MailBootOption } from "./boot-options/mail-option.ts";
|
|
16
17
|
import { type SystemWireDeps } from "./extra-routes-deps.ts";
|
|
18
|
+
import type { ConfigOverridesInput } from "./run-prod-app-boot-context.ts";
|
|
17
19
|
export type WorkerContextOption = Record<string, unknown> | ((deps: WorkerDeps) => Record<string, unknown>);
|
|
18
20
|
export type WorkerDeps = {
|
|
19
21
|
readonly db: DbConnection;
|
|
@@ -39,6 +41,8 @@ export type RunWorkerAppOptions = {
|
|
|
39
41
|
readonly features: readonly FeatureDefinition[];
|
|
40
42
|
/** Opt-in boot-validator warnings — see ValidateBootOptions. */
|
|
41
43
|
readonly validateBootOptions?: ValidateBootOptions;
|
|
44
|
+
/** One table block for every list of the bundled features, see ComposeFeaturesOptions.bundledLists. Must match the API process. */
|
|
45
|
+
readonly bundledLists?: BundledListsOptions;
|
|
42
46
|
/** Mount the auto-mixed config/user/tenant/auth-email-password features —
|
|
43
47
|
* MUST match the API process's `includeBundled` value, otherwise API
|
|
44
48
|
* and worker run with a diverging registry topology. Also controls
|
|
@@ -62,6 +66,12 @@ export type RunWorkerAppOptions = {
|
|
|
62
66
|
/** Subject-key adapter for crypto-shredding — boot checks health()
|
|
63
67
|
* before any connection (see RunProdAppOptions["kms"]). */
|
|
64
68
|
readonly kms?: KmsAdapter;
|
|
69
|
+
/** App-level config values, same semantics and merge as RunProdAppOptions["appOverrides"];
|
|
70
|
+
* the worker renders the mail layouts and must resolve the same branding as the API. */
|
|
71
|
+
readonly appOverrides?: ConfigOverridesInput;
|
|
72
|
+
/** Replaces the real clock, sleep and log sink of the boot backoff for the database and
|
|
73
|
+
* KMS waits; tests use it to run the retry path without real waiting. */
|
|
74
|
+
readonly databaseWait?: DatabaseWaitClock;
|
|
65
75
|
/** Blind-index key for lookupable fields (see
|
|
66
76
|
* RunProdAppOptions["blindIndexKey"]). */
|
|
67
77
|
readonly blindIndexKey?: string;
|
package/dist/run-worker-app.js
CHANGED
|
@@ -27,6 +27,7 @@ import { resolveBootOptionWiring } from "./boot-options/resolve-boot-options.js"
|
|
|
27
27
|
import { assertStackBootRequirements } from "./boot-options/stack-boot-requirements.js";
|
|
28
28
|
import { makeDispatchSystemQuery, makeDispatchSystemWrite, } from "./extra-routes-deps.js";
|
|
29
29
|
import { installFatalErrorHandlers } from "./fatal-error-handlers.js";
|
|
30
|
+
import { makeFilesForTenant } from "./files-for-tenant.js";
|
|
30
31
|
import { installProcessLogger, uninstallProcessLogger } from "./process-logger.js";
|
|
31
32
|
function makeBootModeHandle() {
|
|
32
33
|
return {
|
|
@@ -127,6 +128,7 @@ export async function runWorkerApp(options) {
|
|
|
127
128
|
db,
|
|
128
129
|
redis,
|
|
129
130
|
registry,
|
|
131
|
+
filesForTenant: makeFilesForTenant(entrypoint.context),
|
|
130
132
|
metricsFor: createMetricsForFactory(entrypoint.observability.meter),
|
|
131
133
|
dispatchSystemWrite: makeDispatchSystemWrite(entrypoint.dispatcher),
|
|
132
134
|
dispatchSystemQuery: makeDispatchSystemQuery(entrypoint.dispatcher),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-server-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.389.0",
|
|
4
4
|
"description": "Production server-boot runtime for Kumiko apps: connections, schema-drift-gate, seeds, lifecycle, graceful shutdown. Symmetric to kumiko-dev-server's runDevApp, without dev/scaffold tooling.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -70,6 +70,10 @@
|
|
|
70
70
|
"types": "./dist/renderer-web-fonts.d.ts",
|
|
71
71
|
"default": "./dist/renderer-web-fonts.js"
|
|
72
72
|
},
|
|
73
|
+
"./resolve-installed-package": {
|
|
74
|
+
"types": "./dist/resolve-installed-package.d.ts",
|
|
75
|
+
"default": "./dist/resolve-installed-package.js"
|
|
76
|
+
},
|
|
73
77
|
"./resolve-tailwind-cli": {
|
|
74
78
|
"types": "./dist/resolve-tailwind-cli.d.ts",
|
|
75
79
|
"default": "./dist/resolve-tailwind-cli.js"
|
|
@@ -92,18 +96,21 @@
|
|
|
92
96
|
}
|
|
93
97
|
},
|
|
94
98
|
"dependencies": {
|
|
95
|
-
"@cosmicdrift/kumiko-bundled-features": "0.
|
|
96
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
97
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
98
|
-
"@cosmicdrift/kumiko-renderer-mail-html": "0.
|
|
99
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
100
|
-
"bun-types": "^1.3.13",
|
|
99
|
+
"@cosmicdrift/kumiko-bundled-features": "0.389.0",
|
|
100
|
+
"@cosmicdrift/kumiko-framework": "0.389.0",
|
|
101
|
+
"@cosmicdrift/kumiko-renderer": "0.389.0",
|
|
102
|
+
"@cosmicdrift/kumiko-renderer-mail-html": "0.389.0",
|
|
103
|
+
"@cosmicdrift/kumiko-types": "0.389.0",
|
|
101
104
|
"ioredis": "^5.10.1",
|
|
102
105
|
"temporal-polyfill": "^0.3.2",
|
|
103
106
|
"zod": "^4.4.3"
|
|
104
107
|
},
|
|
108
|
+
"peerDependencies": {
|
|
109
|
+
"bun-types": "^1.4.4"
|
|
110
|
+
},
|
|
105
111
|
"devDependencies": {
|
|
106
|
-
"@cosmicdrift/kumiko-testing": "0.
|
|
112
|
+
"@cosmicdrift/kumiko-testing": "0.389.0",
|
|
113
|
+
"bun-types": "^1.4.4"
|
|
107
114
|
},
|
|
108
115
|
"publishConfig": {
|
|
109
116
|
"registry": "https://registry.npmjs.org",
|
|
@@ -161,6 +168,10 @@
|
|
|
161
168
|
"types": "./dist/renderer-web-fonts.d.ts",
|
|
162
169
|
"default": "./dist/renderer-web-fonts.js"
|
|
163
170
|
},
|
|
171
|
+
"./resolve-installed-package": {
|
|
172
|
+
"types": "./dist/resolve-installed-package.d.ts",
|
|
173
|
+
"default": "./dist/resolve-installed-package.js"
|
|
174
|
+
},
|
|
164
175
|
"./resolve-tailwind-cli": {
|
|
165
176
|
"types": "./dist/resolve-tailwind-cli.d.ts",
|
|
166
177
|
"default": "./dist/resolve-tailwind-cli.js"
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,65 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.389.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "The renderer-web stylesheet fallback checks tailwindcss in node_modules",
|
|
6
|
+
"detail": "canResolveTailwindStylesheet looks for tailwindcss in the node_modules directories above the stylesheet, as Tailwind does, instead of asking Bun.resolveSync. On Bun 1.4.4 the resolver auto-installs tailwindcss next to a renderer copy in Bun's install cache, so a build without a node_modules tree took the fallback and then failed in Tailwind with \"Can't resolve 'tailwindcss'\". Such a build skips the fallback stylesheet again."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.389.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "Package lookups no longer auto-install from the registry",
|
|
12
|
+
"detail": "The new @cosmicdrift/kumiko-server-runtime/resolve-installed-package export looks up node_modules before asking Bun.resolveSync, which installs missing packages from the registry since Bun 1.4.4. The prod build, the dev-server stylesheet lookup and the handbuch commands use it."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.389.0",
|
|
16
|
+
"type": "improvement",
|
|
17
|
+
"title": "bundledLists sets one table block for every bundled list",
|
|
18
|
+
"detail": "composeFeatures takes bundledLists with a table block and an optional pinFirstColumn flag. The block is mixed into every entityList, projectionList and relatedList section of the bundled features, the foundation features user and tenant as well as the features an app mounts from @cosmicdrift/kumiko-bundled-features, before the boot validator runs. A key the screen sets itself wins, export is left out on relatedList sections, and pinFirstColumn pins the first visible column left where no column is pinned yet. grouping and totals are rejected in the shared block. App screens stay untouched. Features opt in with the new registrar method r.adoptsBundledLists(), which every bundled feature with lists calls and which feature packages such as Enterprise can call for their own lists; app features never do. No migration for apps. The option lives in AppConfig.bundledLists for the managed kumiko/schema.ts and in the run options of runProdApp, runDevApp and runWorkerApp, so kumiko check and the running app see the same screens."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"version": "0.389.0",
|
|
22
|
+
"type": "improvement",
|
|
23
|
+
"title": "Boot database backoff takes an injectable clock",
|
|
24
|
+
"detail": "The new databaseWait option on runProdApp and runWorkerApp swaps sleep, now and the log sink of the database, KMS and seed waits. A worker boot against an unreachable database is now tested to retry until the deadline, abort with the connection error and close its Redis clients."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"version": "0.389.0",
|
|
28
|
+
"type": "fix",
|
|
29
|
+
"title": "Default CSP covers inline scripts of HTML written after the build",
|
|
30
|
+
"detail": "At boot the hashes from csp-hashes.json are merged with the inline scripts found in the HTML files under staticDir, for the default CSP and for hostDispatch. Boot-rendered pages keep their inline scripts working without a custom securityHeaders.csp."
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"version": "0.389.0",
|
|
34
|
+
"type": "fix",
|
|
35
|
+
"title": "Read-only demo mode blocks WebSocket upgrades",
|
|
36
|
+
"detail": "Upgrade requests count as mutating in the demo read-only gate and get 403 demo_read_only unless the route path is in allowedWritePaths. Socket routes in a read-only demo that need to stay open must be listed there."
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"version": "0.389.0",
|
|
40
|
+
"type": "breaking",
|
|
41
|
+
"title": "BootOptionName drops demoMode, seeds and signals",
|
|
42
|
+
"detail": "No stack declared them in bootRequires, so their boot checks never ran. A name returns together with its check and a test once a stack needs it.",
|
|
43
|
+
"migration": "no known callers; remove \"demoMode\", \"seeds\" and \"signals\" from bootRequires options"
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"version": "0.389.0",
|
|
47
|
+
"type": "improvement",
|
|
48
|
+
"title": "appOverrides work in runWorkerApp and runDevApp",
|
|
49
|
+
"detail": "Both boot functions take the same appOverrides option as runProdApp and share its merge, so worker-rendered mails and the dev server resolve the same tenant model, branding and SEO values as the API. The runSingleInstance:false warning names appOverrides next to mail, fileStorage and featureToggles."
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"version": "0.389.0",
|
|
53
|
+
"type": "fix",
|
|
54
|
+
"title": "Boot seeds survive a short database outage",
|
|
55
|
+
"detail": "applyStandardBootSeeds re-runs the idempotent seed sequence with the database backoff on transient connection errors (ECONNREFUSED, connection class 08, too_many_connections) until the 90 second deadline. Non-transient errors still abort the boot at once."
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"version": "0.389.0",
|
|
59
|
+
"type": "improvement",
|
|
60
|
+
"title": "filesForTenant in SystemWireDeps",
|
|
61
|
+
"detail": "The wire hook and the worker wireComponents receive filesForTenant, built from the AppContext after buildServer wired it. ApiEntrypoint and WorkerEntrypoint expose that context as entrypoint.context."
|
|
62
|
+
},
|
|
2
63
|
{
|
|
3
64
|
"version": "0.388.0",
|
|
4
65
|
"type": "improvement",
|