@cosmicdrift/kumiko-dev-server 0.386.0 → 0.388.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/LICENSE +21 -57
- package/dist/build-server-bundle.d.ts +7 -7
- package/dist/build-server-bundle.js +53 -53
- package/dist/build.js +3 -3
- package/dist/compose-stacks.d.ts +2 -2
- package/dist/compose-stacks.js +1 -2
- package/dist/crash-tracker.d.ts +3 -7
- package/dist/crash-tracker.js +4 -13
- package/dist/create-kumiko-server-client-bundles.d.ts +21 -0
- package/dist/create-kumiko-server-client-bundles.js +97 -0
- package/dist/create-kumiko-server-html.d.ts +21 -0
- package/dist/create-kumiko-server-html.js +132 -0
- package/dist/create-kumiko-server-public-files.d.ts +6 -0
- package/dist/create-kumiko-server-public-files.js +96 -0
- package/dist/create-kumiko-server-reload.d.ts +14 -0
- package/dist/create-kumiko-server-reload.js +73 -0
- package/dist/create-kumiko-server-routes.d.ts +16 -0
- package/dist/create-kumiko-server-routes.js +206 -0
- package/dist/create-kumiko-server-shutdown.d.ts +14 -0
- package/dist/create-kumiko-server-shutdown.js +72 -0
- package/dist/create-kumiko-server-stack.d.ts +12 -0
- package/dist/create-kumiko-server-stack.js +101 -0
- package/dist/create-kumiko-server-stylesheet.d.ts +10 -0
- package/dist/create-kumiko-server-stylesheet.js +137 -0
- package/dist/create-kumiko-server-watcher.d.ts +22 -0
- package/dist/create-kumiko-server-watcher.js +155 -0
- package/dist/create-kumiko-server.d.ts +53 -71
- package/dist/create-kumiko-server.js +59 -1076
- package/dist/dev-server-runtime.d.ts +5 -0
- package/dist/dev-server-runtime.js +11 -0
- package/dist/env-schema.d.ts +1 -1
- package/dist/env-schema.js +4 -4
- package/dist/index.d.ts +3 -1
- package/dist/index.js +17 -15
- package/dist/managed-files/app-jobs.js +1 -1
- package/dist/managed-files/check.js +1 -1
- package/dist/run-dev-app-auth.d.ts +38 -0
- package/dist/run-dev-app-auth.js +155 -0
- package/dist/run-dev-app-config.d.ts +26 -0
- package/dist/run-dev-app-config.js +185 -0
- package/dist/run-dev-app-server-options.d.ts +13 -0
- package/dist/run-dev-app-server-options.js +49 -0
- package/dist/run-dev-app.d.ts +52 -50
- package/dist/run-dev-app.js +100 -471
- package/dist/scaffold-app-feature.js +5 -4
- package/dist/scaffold-app.js +5 -7
- package/dist/schema-apply.d.ts +3 -3
- package/dist/schema-apply.js +16 -16
- package/dist/schema-tables-auth-mode.js +16 -15
- package/dist/schema-tables-minimal.js +7 -7
- package/dist/setup-test-stack-from-features.js +1 -1
- package/dist/welcome-banner.js +2 -2
- package/dist/write-server-lockfile.js +23 -16
- package/package.json +10 -10
- package/src/changes.json +30 -0
- package/templates/managed/ci-public.yml.template +4 -0
package/dist/run-dev-app.js
CHANGED
|
@@ -1,231 +1,113 @@
|
|
|
1
1
|
// High-level dev-server wrapper for sample apps and showcases. In auth mode it
|
|
2
2
|
// mixes in the standard features (config/user/tenant/auth-email-password),
|
|
3
|
-
// wires AuthRoutesConfig plus the login error map and calls seedAdminGuarded()
|
|
4
|
-
// in onAfterSetup,
|
|
3
|
+
// wires AuthRoutesConfig plus the login error map, and calls seedAdminGuarded()
|
|
4
|
+
// in onAfterSetup, reducing a sample bootstrap from roughly 50 lines to 5–10.
|
|
5
5
|
//
|
|
6
|
-
// Auto-
|
|
7
|
-
//
|
|
8
|
-
// Showcases
|
|
9
|
-
//
|
|
6
|
+
// Auto-mixing occurs only in auth mode. Without an `auth` block, the server
|
|
7
|
+
// remains in auto-mint-JWT mode (the dev default) and adds none of these
|
|
8
|
+
// features. Showcases that demonstrate a single domain without a login flow
|
|
9
|
+
// therefore do not unexpectedly gain four additional registry features.
|
|
10
10
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
import { AuthErrors, AuthHandlers, AuthQueries, } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
|
|
15
|
-
import { seedAdminGuarded, } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/seeding";
|
|
16
|
-
import { EXT_SESSION_STORE, resolveSessionStore, resolveTokenVerifier, } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
17
|
-
import { AUTH_MFA_FEATURE, AuthMfaHandlers, bindMfaRevokeAllOtherSessionsFromFeature, bindMfaTokenSecretsFromFeature, bindRevokeAllPatTokensFromFeature, } from "@cosmicdrift/kumiko-bundled-features/auth-mfa";
|
|
11
|
+
// For maximum control, such as custom auth wiring, a different membership
|
|
12
|
+
// query, or a custom LoginRateLimiter, use `createKumikoServer` directly from
|
|
13
|
+
// @cosmicdrift/kumiko-dev-server.
|
|
18
14
|
import { buildEnvConfigOverrides, createConfigResolver, } from "@cosmicdrift/kumiko-bundled-features/config";
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import { collectWriteHandlerQns, createRegistry, FILE_STORAGE_PROVIDER_BOOT_SENTINEL, FILE_STORAGE_PROVIDER_ENV, findTierResolverUsage, requireTierResolverPlugin, validateAppCustomScreenWriteQns, validateBoot, } from "@cosmicdrift/kumiko-framework/engine";
|
|
26
|
-
import { parseEnv } from "@cosmicdrift/kumiko-framework/env";
|
|
27
|
-
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
28
|
-
import { warnIfNonUtcServerTimeZone } from "@cosmicdrift/kumiko-framework/time";
|
|
29
|
-
import { assertStackBootRequirements, isSeedFnList, rejectRemovedEffectiveFeatures, resolveBootOptionWiring, withBootOptionEnvBlocks, } from "@cosmicdrift/kumiko-server-runtime";
|
|
30
|
-
import { applyBootSeeds } from "@cosmicdrift/kumiko-server-runtime/boot/apply-boot-seeds";
|
|
31
|
-
import { resolveBootCrypto } from "@cosmicdrift/kumiko-server-runtime/boot/boot-crypto";
|
|
32
|
-
import { buildComposeAuthOptions, composeFeatures, } from "@cosmicdrift/kumiko-server-runtime/compose-features";
|
|
33
|
-
import { assertPiiBootInvariants } from "@cosmicdrift/kumiko-server-runtime/pii-boot-gate";
|
|
34
|
-
import { assertSessionBootInvariants } from "@cosmicdrift/kumiko-server-runtime/session-boot-gate";
|
|
35
|
-
import { createKumikoServer, logInfo, } from "./create-kumiko-server.js";
|
|
36
|
-
import { captureEphemeralDevPiiKms, resolveDevPiiKms, } from "./resolve-dev-pii-kms.js";
|
|
15
|
+
import { parseTrustedProxyHopsEnv, TRUSTED_PROXY_HOPS_ENV, } from "@cosmicdrift/kumiko-framework/api";
|
|
16
|
+
import { findTierResolverUsage, } from "@cosmicdrift/kumiko-framework/engine";
|
|
17
|
+
import { createKumikoServer, } from "./create-kumiko-server.js";
|
|
18
|
+
import { createDevLateBindings, createLateBoundEffectiveFeatures, runDevAfterSetup, wireDevAuth, } from "./run-dev-app-auth.js";
|
|
19
|
+
import { configureDevBootCrypto, resolveDevAuthRegistry, resolveDevBootConfig, validateDevBoot, } from "./run-dev-app-config.js";
|
|
20
|
+
import { buildDevServerOptions } from "./run-dev-app-server-options.js";
|
|
37
21
|
import { renderWelcomeBanner } from "./welcome-banner.js";
|
|
38
|
-
import { addConfigAccessorFactory, buildBootExtraContext, loggerFromExtraContext,
|
|
22
|
+
import { addConfigAccessorFactory, buildBootExtraContext, loggerFromExtraContext, } from "@cosmicdrift/kumiko-server-runtime/run-prod-app";
|
|
39
23
|
export async function runDevApp(options) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
// auseinanderdriften können).
|
|
44
|
-
const rawEnvSource = options.envSource ?? process.env;
|
|
45
|
-
const seedsConfig = options.seeds !== undefined && !isSeedFnList(options.seeds) ? options.seeds : undefined;
|
|
46
|
-
const stackSeeds = options.seeds === undefined
|
|
47
|
-
? []
|
|
48
|
-
: isSeedFnList(options.seeds)
|
|
49
|
-
? options.seeds
|
|
50
|
-
: (options.seeds.run ?? []);
|
|
51
|
-
if (options.envSchema) {
|
|
52
|
-
// Free dev seeds take the test stack; the env blocks only read the declarative part.
|
|
53
|
-
const { seeds: _devSeeds, kms: devKms, ...bootFields } = options;
|
|
54
|
-
const composed = withBootOptionEnvBlocks(options.envSchema, {
|
|
55
|
-
...bootFields,
|
|
56
|
-
...(devKms && { kms: devKms }),
|
|
57
|
-
...(seedsConfig && { seeds: { ...seedsConfig, run: [] } }),
|
|
58
|
-
features: options.features,
|
|
59
|
-
});
|
|
60
|
-
parseEnv(composed.schema, rawEnvSource, { sources: composed.sources });
|
|
24
|
+
const bootFailure = { release: undefined };
|
|
25
|
+
try {
|
|
26
|
+
return await bootDevApp(options, bootFailure);
|
|
61
27
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
})
|
|
90
|
-
:
|
|
91
|
-
const composeAuthOptions = buildComposeAuthOptions(effectiveAuth);
|
|
92
|
-
const { unsubscribeLinks, unsubscribeRoutes } = resolveUnsubscribeWiring({
|
|
93
|
-
unsubscribe: options.unsubscribe,
|
|
94
|
-
publicOrigin: undefined,
|
|
95
|
-
mailBaseUrl: effectiveAuth?.mail?.baseUrl,
|
|
96
|
-
envSource,
|
|
97
|
-
warn: (message) => createFallbackLogger("runDevApp").warn(message),
|
|
98
|
-
});
|
|
99
|
-
const extraRoutes = unsubscribeRoutes.length === 0 && wiring.extraRoutes.length === 0
|
|
100
|
-
? options.extraRoutes
|
|
101
|
-
: [...(options.extraRoutes ?? []), ...unsubscribeRoutes, ...wiring.extraRoutes];
|
|
102
|
-
const features = composeFeatures([...options.features, ...wiring.features], {
|
|
103
|
-
includeBundled: !!effectiveAuth,
|
|
104
|
-
...(composeAuthOptions && { authOptions: composeAuthOptions }),
|
|
105
|
-
});
|
|
106
|
-
// An explicitly wired file provider (options.files) satisfies the
|
|
107
|
-
// FILE_STORAGE_PROVIDER boot gate. Passed via the validateBoot env option
|
|
108
|
-
// instead of mutating process.env, so nothing leaks into later runDevApp
|
|
109
|
-
// calls in the same process.
|
|
110
|
-
const baseBootEnv = {
|
|
111
|
-
...wiring.envDefaults,
|
|
112
|
-
...(options.validateBootOptions?.env ?? process.env),
|
|
113
|
-
};
|
|
114
|
-
const hasBootEnvDefaults = Object.keys(wiring.envDefaults).length > 0;
|
|
115
|
-
const bootOptions = options.files !== undefined && baseBootEnv[FILE_STORAGE_PROVIDER_ENV] === undefined
|
|
116
|
-
? {
|
|
117
|
-
...options.validateBootOptions,
|
|
118
|
-
env: { ...baseBootEnv, [FILE_STORAGE_PROVIDER_ENV]: FILE_STORAGE_PROVIDER_BOOT_SENTINEL },
|
|
119
|
-
}
|
|
120
|
-
: hasBootEnvDefaults
|
|
121
|
-
? { ...options.validateBootOptions, env: baseBootEnv }
|
|
122
|
-
: options.validateBootOptions;
|
|
123
|
-
// Boot-Validation als allererstes — vor dem Server. Dieselbe
|
|
124
|
-
// Fehlerklasse (unqualifizierte nav-/handler-QNs, screen-access etc.),
|
|
125
|
-
// die früher nur runProdApp fing und sonst erst den Prod-Pod im
|
|
126
|
-
// CrashLoopBackOff sterben ließ (#359). Wirft synchron, bevor ein
|
|
127
|
-
// Socket aufgeht.
|
|
128
|
-
// Resolved before validateBoot: an app-supplied masterKey provider must satisfy the
|
|
129
|
-
// encrypted-field gate here exactly as it does in runProdApp.
|
|
130
|
-
const bootCrypto = resolveBootCrypto(envSource, options.masterKey);
|
|
131
|
-
validateBoot(features, {
|
|
132
|
-
...(bootCrypto.entityFieldCipher && { entityFieldCipher: bootCrypto.entityFieldCipher }),
|
|
133
|
-
...bootOptions,
|
|
28
|
+
catch (error) {
|
|
29
|
+
// Anything that throws after the KMS wiring is open (boot gates, server creation) must close it.
|
|
30
|
+
await bootFailure.release?.().catch(() => { });
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async function bootDevApp(options, bootFailure) {
|
|
35
|
+
const config = await resolveDevBootConfig(options, bootFailure);
|
|
36
|
+
const bootCrypto = validateDevBoot(options, config);
|
|
37
|
+
const lateBindings = createDevLateBindings();
|
|
38
|
+
const tierResolverUsage = findTierResolverUsage(config.features);
|
|
39
|
+
const effectiveFeatures = createLateBoundEffectiveFeatures(lateBindings, Boolean(tierResolverUsage || config.wiring.featureToggles));
|
|
40
|
+
const { releasePiiKms } = configureDevBootCrypto(options, config, bootCrypto, lateBindings.resets, bootFailure);
|
|
41
|
+
const { registry, sessionStoreProviderMounted } = resolveDevAuthRegistry(config);
|
|
42
|
+
const extraContext = buildDevExtraContext(options, config, registry, bootCrypto);
|
|
43
|
+
const auth = wireDevAuth(config, registry, sessionStoreProviderMounted, lateBindings);
|
|
44
|
+
const trustedProxyHops = options.trustedProxyHops ??
|
|
45
|
+
config.effectiveAuth?.trustedProxyHops ??
|
|
46
|
+
parseTrustedProxyHopsEnv(config.envSource[TRUSTED_PROXY_HOPS_ENV], "runDevApp");
|
|
47
|
+
const handle = await createKumikoServer({
|
|
48
|
+
...buildDevServerOptions({
|
|
49
|
+
options,
|
|
50
|
+
config,
|
|
51
|
+
auth,
|
|
52
|
+
extraContext,
|
|
53
|
+
trustedProxyHops,
|
|
54
|
+
effectiveFeatures,
|
|
55
|
+
}),
|
|
56
|
+
onAfterSetup: (stack) => runDevAfterSetup(stack, { config, bootCrypto, auth, lateBindings }),
|
|
134
57
|
});
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
};
|
|
147
|
-
const finalEffectiveFeatures = tierResolverUsage || wiring.featureToggles
|
|
148
|
-
? Object.assign((tenantId) => {
|
|
149
|
-
// Defensive: Server starts AFTER onAfterSetup completes, so the
|
|
150
|
-
// resolver is filled before any request comes in. Throwing here
|
|
151
|
-
// means a programming error (boot order) rather than silent
|
|
152
|
-
// "all-features-on" misbehavior.
|
|
153
|
-
if (!tierResolverHolder.resolver) {
|
|
154
|
-
throw new Error("tier-resolver: extension found but resolver not yet built — boot order issue?");
|
|
58
|
+
bootFailure.release = undefined;
|
|
59
|
+
if (options.welcomeBanner) {
|
|
60
|
+
const banner = renderDevWelcomeBanner(options, config, handle);
|
|
61
|
+
// biome-ignore lint/suspicious/noConsole: boot-time UX print, opt-in via welcomeBanner.
|
|
62
|
+
console.log(`\n${banner}\n`);
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
...handle,
|
|
66
|
+
stop: async () => {
|
|
67
|
+
try {
|
|
68
|
+
await handle.stop();
|
|
155
69
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
// Wrapper-Closure vor build() konstruiert wird. Kein Trial → false.
|
|
161
|
-
trialGate: (tenantId, featureName) => tierResolverHolder.resolver?.trialGate?.(tenantId, featureName) ??
|
|
162
|
-
Promise.resolve(false),
|
|
163
|
-
})
|
|
164
|
-
: undefined;
|
|
165
|
-
// configResolver-default fürs config-feature — im auth-mode immer
|
|
166
|
-
// hinzufügen, im no-auth-mode dem Caller überlassen. Factory-form
|
|
167
|
-
// wird gewrap't damit der spread auf das aufgerufene Result greift,
|
|
168
|
-
// nicht auf die function selbst (no-op). Die ENV→config-Brücke (Keys mit
|
|
169
|
-
// `env:` → app-override-Default) läuft symmetrisch zu runProdApp; die
|
|
170
|
-
// throwaway-Registry hier extrahiert nur die config-Keys, weil der
|
|
171
|
-
// configResolver vor dem Server-Boot konstruiert wird (stack.registry
|
|
172
|
-
// gibt's erst onAfterSetup) — createKumikoServer baut intern seine eigene.
|
|
173
|
-
// App-wide cipher for `encrypted: true` entity fields (symmetrisch zu
|
|
174
|
-
// runProdApp) — executors resolve it lazily.
|
|
175
|
-
configureEntityFieldEncryption(bootCrypto.entityFieldCipher);
|
|
176
|
-
// Subject-KMS + Blind-Index (symmetrisch zu runProdApp). Dev warnt bei
|
|
177
|
-
// Klartext-PII statt zu failen; kms+lookupable ohne blindIndexKey bricht
|
|
178
|
-
// auch hier (Lookups wären in jedem Modus kaputt).
|
|
179
|
-
const piiKms = options.kmsMode !== undefined
|
|
180
|
-
? {
|
|
181
|
-
kms: wiring.kms.kms,
|
|
182
|
-
blindIndexKey: wiring.kms.blindIndexKey,
|
|
183
|
-
generatedBlindIndexKey: false,
|
|
184
|
-
mode: wiring.kms.kms ? "app-kms" : "plaintext",
|
|
185
|
-
}
|
|
186
|
-
: resolveDevPiiKms(options);
|
|
187
|
-
configurePiiSubjectKms(piiKms.kms);
|
|
188
|
-
configureBlindIndexKey(piiKms.blindIndexKey);
|
|
189
|
-
const releaseEphemeralPiiKms = captureEphemeralDevPiiKms(piiKms);
|
|
190
|
-
// The auth features bind database-backed revokers onto their (possibly shared) instances;
|
|
191
|
-
// stop() swaps them for inert ones so a later stack in the same process never reaches this
|
|
192
|
-
// app's closed connection.
|
|
193
|
-
const resetLateBindings = [];
|
|
194
|
-
const releasePiiKms = async () => {
|
|
195
|
-
for (const reset of resetLateBindings)
|
|
196
|
-
reset();
|
|
197
|
-
releaseEphemeralPiiKms();
|
|
198
|
-
await wiring.kms.close();
|
|
70
|
+
finally {
|
|
71
|
+
await releasePiiKms();
|
|
72
|
+
}
|
|
73
|
+
},
|
|
199
74
|
};
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
assertSessionBootInvariants({
|
|
216
|
-
hasAuth: Boolean(effectiveAuth),
|
|
217
|
-
sessionStoreProviderMounted,
|
|
218
|
-
mode: "dev",
|
|
75
|
+
}
|
|
76
|
+
function renderDevWelcomeBanner(options, config, handle) {
|
|
77
|
+
const { effectiveAuth } = config;
|
|
78
|
+
const overrides = typeof options.welcomeBanner === "object" ? options.welcomeBanner : {};
|
|
79
|
+
const port = handle.server?.port ?? options.port ?? 3000;
|
|
80
|
+
return renderWelcomeBanner({
|
|
81
|
+
url: `http://localhost:${port}`,
|
|
82
|
+
...(effectiveAuth?.admin && {
|
|
83
|
+
admin: {
|
|
84
|
+
email: effectiveAuth.admin.email,
|
|
85
|
+
password: effectiveAuth.admin.password,
|
|
86
|
+
},
|
|
87
|
+
}),
|
|
88
|
+
...(overrides.featuresDir !== undefined && { featuresDir: overrides.featuresDir }),
|
|
89
|
+
...(overrides.docsUrl !== undefined && { docsUrl: overrides.docsUrl }),
|
|
219
90
|
});
|
|
91
|
+
}
|
|
92
|
+
// Provide the configResolver default for the config feature in auth mode;
|
|
93
|
+
// in no-auth mode, leave that choice to the caller. Wrap the factory so the
|
|
94
|
+
// spread applies to its result; spreading the function itself is a no-op. The
|
|
95
|
+
// ENV-to-config bridge supplies app-override defaults from `env:` keys.
|
|
96
|
+
// The temporary registry extracts only config keys because the resolver is
|
|
97
|
+
// built before server boot, and stack.registry only exists from onAfterSetup;
|
|
98
|
+
// createKumikoServer builds its own registry internally.
|
|
99
|
+
// Automatically wire templateResolver and feature-gated secrets, symmetrically
|
|
100
|
+
// with runProdApp. Unlike prod, the database is available here only in the
|
|
101
|
+
// factory dependencies because createKumikoServer builds the stack. Therefore
|
|
102
|
+
// use a factory: buildBootExtraContext runs with hasAuth:false because
|
|
103
|
+
// configResolver already comes from cfgExtra, and cfgExtra values override
|
|
104
|
+
// the boot defaults.
|
|
105
|
+
function buildDevExtraContext(options, config, registry, bootCrypto) {
|
|
106
|
+
const { features, envSource, unsubscribeLinks } = config;
|
|
220
107
|
const cfgExtra = registry
|
|
221
108
|
? mergeConfigResolverDefault(options.extraContext, registry, envSource, bootCrypto.configCipher)
|
|
222
109
|
: options.extraContext;
|
|
223
|
-
|
|
224
|
-
// runProdApp. Anders als prod existiert die db hier erst im Factory-deps
|
|
225
|
-
// (createKumikoServer baut den Stack), darum als Factory: buildBootExtraContext
|
|
226
|
-
// mit hasAuth:false (configResolver kommt schon aus cfgExtra), App-Werte
|
|
227
|
-
// (cfgExtra) gewinnen über die Boot-Defaults.
|
|
228
|
-
const extraContext = (deps) => {
|
|
110
|
+
return (deps) => {
|
|
229
111
|
const base = typeof cfgExtra === "function" ? cfgExtra(deps) : (cfgExtra ?? {});
|
|
230
112
|
const bootLogger = loggerFromExtraContext(base);
|
|
231
113
|
const boot = buildBootExtraContext({
|
|
@@ -246,260 +128,6 @@ export async function runDevApp(options) {
|
|
|
246
128
|
...base,
|
|
247
129
|
};
|
|
248
130
|
};
|
|
249
|
-
// Sessions: late-bound holder — resolveSessionStore needs concrete db+
|
|
250
|
-
// registry (only after setupTestStack). Wired when sessions feature is
|
|
251
|
-
// mounted (#1372); no auth.sessions opt-in anymore.
|
|
252
|
-
let sessionStore;
|
|
253
|
-
const requireSessionStore = () => {
|
|
254
|
-
if (!sessionStore) {
|
|
255
|
-
throw new Error("[runDevApp] sessionStore accessed before onAfterSetup");
|
|
256
|
-
}
|
|
257
|
-
return sessionStore;
|
|
258
|
-
};
|
|
259
|
-
// Token-verifier opt-in: same late-bound holder pattern — resolveTokenVerifier
|
|
260
|
-
// needs the real db+registry (only concrete after setupTestStack). Wired
|
|
261
|
-
// whenever a provider feature (personal-access-tokens today) is mounted.
|
|
262
|
-
let tokenVerifier;
|
|
263
|
-
const patFeature = features.find((f) => f.name === PAT_FEATURE);
|
|
264
|
-
const mfaFeature = features.find((f) => f.name === AUTH_MFA_FEATURE);
|
|
265
|
-
// Same master secret as runProdApp. Without JWT_SECRET the feature keeps
|
|
266
|
-
// its explicit secrets, or its handlers report the missing binding.
|
|
267
|
-
const devMasterSecret = envSource["JWT_SECRET"];
|
|
268
|
-
if (mfaFeature && devMasterSecret) {
|
|
269
|
-
bindMfaTokenSecretsFromFeature(mfaFeature)?.(devMasterSecret, envSource);
|
|
270
|
-
}
|
|
271
|
-
if (effectiveAuth &&
|
|
272
|
-
mfaFeature &&
|
|
273
|
-
registry?.getWriteHandler(AuthHandlers.switchTenantMfaGate) === undefined) {
|
|
274
|
-
throw new Error(`[runDevApp] auth-mfa is mounted but ${AuthHandlers.switchTenantMfaGate} is not registered: ` +
|
|
275
|
-
"createAuthEmailPasswordFeature registers it only when mfaStatusChecker is set, and without it " +
|
|
276
|
-
"POST /auth/switch-tenant would mint sessions without the second factor");
|
|
277
|
-
}
|
|
278
|
-
const patAuthFragment = patFeature
|
|
279
|
-
? {
|
|
280
|
-
tokenVerifier: (rawToken) => {
|
|
281
|
-
if (!tokenVerifier) {
|
|
282
|
-
throw new Error("[runDevApp] token-verifier accessed before onAfterSetup");
|
|
283
|
-
}
|
|
284
|
-
return tokenVerifier(rawToken);
|
|
285
|
-
},
|
|
286
|
-
patRateLimiter: (() => {
|
|
287
|
-
const rl = patRateLimitFromFeature(patFeature);
|
|
288
|
-
return createInMemoryLoginRateLimiter(rl.maxRequests, rl.windowMs);
|
|
289
|
-
})(),
|
|
290
|
-
}
|
|
291
|
-
: {};
|
|
292
|
-
// Parity with runProdApp (#1372 review): gate on sessionStore provider, not
|
|
293
|
-
// the sessions feature name — a custom provider must also wire auth
|
|
294
|
-
// callbacks. sessionStoreProviderMounted computed above via the registry
|
|
295
|
-
// (same check as runProdApp, #2027) — not recomputed here.
|
|
296
|
-
const sessionAuthFragment = effectiveAuth && sessionStoreProviderMounted
|
|
297
|
-
? {
|
|
298
|
-
sessionCreator: (user, meta) => requireSessionStore().creator(user, meta),
|
|
299
|
-
sessionRevoker: (sid) => requireSessionStore().revoker(sid),
|
|
300
|
-
sessionChecker: (sid, userId) => requireSessionStore().checker(sid, userId),
|
|
301
|
-
}
|
|
302
|
-
: {};
|
|
303
|
-
const trustedProxyHops = options.trustedProxyHops ??
|
|
304
|
-
effectiveAuth?.trustedProxyHops ??
|
|
305
|
-
parseTrustedProxyHopsEnv(envSource[TRUSTED_PROXY_HOPS_ENV], "runDevApp");
|
|
306
|
-
const handle = await createKumikoServer({
|
|
307
|
-
features,
|
|
308
|
-
...(options.clientEntry !== undefined && { clientEntry: options.clientEntry }),
|
|
309
|
-
...(options.errorDocs && { errorDocs: options.errorDocs }),
|
|
310
|
-
...(options.clientEntries !== undefined && { clientEntries: options.clientEntries }),
|
|
311
|
-
...(options.hostDispatch !== undefined && { hostDispatch: options.hostDispatch }),
|
|
312
|
-
...(options.resolvePageHead !== undefined && { resolvePageHead: options.resolvePageHead }),
|
|
313
|
-
...(options.stylesheet !== undefined && { stylesheet: options.stylesheet }),
|
|
314
|
-
...(options.stylesheetWatch !== undefined && { stylesheetWatch: options.stylesheetWatch }),
|
|
315
|
-
...(options.htmlPath !== undefined && { htmlPath: options.htmlPath }),
|
|
316
|
-
...(options.port !== undefined && { port: options.port }),
|
|
317
|
-
...(options.watchDirs !== undefined && { watchDirs: options.watchDirs }),
|
|
318
|
-
...(options.installSignalHandlers !== undefined && {
|
|
319
|
-
installSignalHandlers: options.installSignalHandlers,
|
|
320
|
-
}),
|
|
321
|
-
...(extraContext !== undefined && { extraContext }),
|
|
322
|
-
...(options.anonymousAccess !== undefined && { anonymousAccess: options.anonymousAccess }),
|
|
323
|
-
...(options.files !== undefined && { files: options.files }),
|
|
324
|
-
...(extraRoutes !== undefined && { extraRoutes }),
|
|
325
|
-
...(options.wire !== undefined && { wire: options.wire }),
|
|
326
|
-
...(trustedProxyHops !== undefined && { trustedProxyHops }),
|
|
327
|
-
...(options.demoMode && { wrapFetch: wiring.wrapFetch }),
|
|
328
|
-
...(finalEffectiveFeatures !== undefined && {
|
|
329
|
-
effectiveFeatures: finalEffectiveFeatures,
|
|
330
|
-
}),
|
|
331
|
-
...(effectiveAuth && {
|
|
332
|
-
auth: {
|
|
333
|
-
membershipQuery: TenantQueries.memberships,
|
|
334
|
-
loginHandler: AuthHandlers.login,
|
|
335
|
-
loginErrorStatusMap: effectiveAuth.loginErrorStatusMap ?? {
|
|
336
|
-
[AuthErrors.invalidCredentials]: 401,
|
|
337
|
-
[AuthErrors.noMembership]: 403,
|
|
338
|
-
},
|
|
339
|
-
...(effectiveAuth.cookieDomain !== undefined && {
|
|
340
|
-
cookieDomain: effectiveAuth.cookieDomain,
|
|
341
|
-
}),
|
|
342
|
-
...(effectiveAuth.retiredCookieDomains !== undefined && {
|
|
343
|
-
retiredCookieDomains: effectiveAuth.retiredCookieDomains,
|
|
344
|
-
}),
|
|
345
|
-
...(effectiveAuth.allowedOrigins !== undefined && {
|
|
346
|
-
allowedOrigins: effectiveAuth.allowedOrigins,
|
|
347
|
-
}),
|
|
348
|
-
...(effectiveAuth.unsafeSkipOriginCheck !== undefined && {
|
|
349
|
-
unsafeSkipOriginCheck: effectiveAuth.unsafeSkipOriginCheck,
|
|
350
|
-
}),
|
|
351
|
-
...(effectiveAuth.trustedProxyHops !== undefined && {
|
|
352
|
-
trustedProxyHops: effectiveAuth.trustedProxyHops,
|
|
353
|
-
}),
|
|
354
|
-
...(effectiveAuth.postAuthLanding !== undefined && {
|
|
355
|
-
postAuthLanding: effectiveAuth.postAuthLanding,
|
|
356
|
-
}),
|
|
357
|
-
...sessionAuthFragment,
|
|
358
|
-
...patAuthFragment,
|
|
359
|
-
...(mfaFeature && {
|
|
360
|
-
mfaVerifyHandler: AuthMfaHandlers.verify,
|
|
361
|
-
switchTenantMfaGateHandler: AuthHandlers.switchTenantMfaGate,
|
|
362
|
-
mfaPreauthEnableStartHandler: AuthMfaHandlers.enableStartPreauth,
|
|
363
|
-
mfaPreauthConfirmHandler: AuthMfaHandlers.enableConfirmPreauth,
|
|
364
|
-
}),
|
|
365
|
-
...(effectiveAuth.passwordReset && {
|
|
366
|
-
passwordReset: {
|
|
367
|
-
requestHandler: AuthHandlers.requestPasswordReset,
|
|
368
|
-
confirmHandler: AuthHandlers.resetPassword,
|
|
369
|
-
},
|
|
370
|
-
}),
|
|
371
|
-
...(effectiveAuth.emailVerification && {
|
|
372
|
-
emailVerification: {
|
|
373
|
-
requestHandler: AuthHandlers.requestEmailVerification,
|
|
374
|
-
confirmHandler: AuthHandlers.verifyEmail,
|
|
375
|
-
},
|
|
376
|
-
}),
|
|
377
|
-
...(effectiveAuth.accountUnlock && {
|
|
378
|
-
accountUnlock: {
|
|
379
|
-
requestHandler: AuthHandlers.requestAccountUnlock,
|
|
380
|
-
confirmHandler: AuthHandlers.confirmAccountUnlock,
|
|
381
|
-
},
|
|
382
|
-
}),
|
|
383
|
-
...(effectiveAuth.signup && {
|
|
384
|
-
signup: {
|
|
385
|
-
requestHandler: AuthHandlers.signupRequest,
|
|
386
|
-
confirmHandler: AuthHandlers.signupConfirm,
|
|
387
|
-
},
|
|
388
|
-
}),
|
|
389
|
-
...(effectiveAuth.invite && {
|
|
390
|
-
invite: {
|
|
391
|
-
acceptHandler: AuthHandlers.inviteAccept,
|
|
392
|
-
acceptWithLoginHandler: AuthHandlers.inviteAcceptWithLogin,
|
|
393
|
-
signupCompleteHandler: AuthHandlers.inviteSignupComplete,
|
|
394
|
-
infoHandler: AuthQueries.inviteInfo,
|
|
395
|
-
},
|
|
396
|
-
}),
|
|
397
|
-
},
|
|
398
|
-
}),
|
|
399
|
-
onAfterSetup: async (stack) => {
|
|
400
|
-
// Sprint-8a: build tier-resolver BEFORE any seeds so seeds can rely
|
|
401
|
-
// on the resolver being live (e.g. seed that writes a SystemAdmin's
|
|
402
|
-
// tier-assignment can immediately read tier-cuts).
|
|
403
|
-
if (tierResolverUsage) {
|
|
404
|
-
tierResolverHolder.resolver = await requireTierResolverPlugin(tierResolverUsage).build({
|
|
405
|
-
db: stack.db,
|
|
406
|
-
registry: stack.registry,
|
|
407
|
-
cacheSync: stack.cacheSync,
|
|
408
|
-
});
|
|
409
|
-
}
|
|
410
|
-
if (wiring.featureToggles) {
|
|
411
|
-
const bound = await wiring.featureToggles.bind({
|
|
412
|
-
db: stack.db,
|
|
413
|
-
registry: stack.registry,
|
|
414
|
-
cacheSync: stack.cacheSync,
|
|
415
|
-
tierResolver: tierResolverHolder.resolver,
|
|
416
|
-
});
|
|
417
|
-
tierResolverHolder.resolver = bound.resolver;
|
|
418
|
-
}
|
|
419
|
-
if (effectiveAuth && stack.registry.getExtensionUsages(EXT_SESSION_STORE).length > 0) {
|
|
420
|
-
// Secure-by-default (#1372): resolve sessionStore provider; bind
|
|
421
|
-
// password-change mass-revoke + MFA revokeAllOthers.
|
|
422
|
-
const store = await resolveSessionStore({
|
|
423
|
-
db: stack.db,
|
|
424
|
-
registry: stack.registry,
|
|
425
|
-
});
|
|
426
|
-
const sessionsFeature = features.find((f) => f.name === SESSIONS_FEATURE);
|
|
427
|
-
if (sessionsFeature) {
|
|
428
|
-
bindReleasable(bindAutoRevokeFromFeature(sessionsFeature), store.massRevoker, INERT_REVOKER, resetLateBindings);
|
|
429
|
-
}
|
|
430
|
-
if (mfaFeature) {
|
|
431
|
-
bindReleasable(bindMfaRevokeAllOtherSessionsFromFeature(mfaFeature), store.revokeAllOthers, INERT_REVOKER, resetLateBindings);
|
|
432
|
-
}
|
|
433
|
-
if (mfaFeature && patFeature) {
|
|
434
|
-
bindReleasable(bindRevokeAllPatTokensFromFeature(mfaFeature), (userId) => revokeAllPatTokensForUser(stack.db, userId), INERT_REVOKER, resetLateBindings);
|
|
435
|
-
}
|
|
436
|
-
sessionStore = store;
|
|
437
|
-
}
|
|
438
|
-
if (patFeature) {
|
|
439
|
-
tokenVerifier = (rawToken) => resolveTokenVerifier({ db: stack.db, registry: stack.registry }, rawToken);
|
|
440
|
-
// Password-change → PAT-revoke: independent of sessions/MFA being
|
|
441
|
-
// mounted, so wired unconditionally here whenever PAT itself is.
|
|
442
|
-
bindReleasable(bindPatAutoRevokeOnPasswordChangeFromFeature(patFeature), (userId) => revokeAllPatTokensForUser(stack.db, userId), INERT_REVOKER, resetLateBindings);
|
|
443
|
-
}
|
|
444
|
-
if (effectiveAuth) {
|
|
445
|
-
await seedAdminGuarded(stack.db, effectiveAuth.admin);
|
|
446
|
-
}
|
|
447
|
-
// Apply r.config({ seeds }) declared by any registered feature.
|
|
448
|
-
// Runs before user-supplied seed callbacks so those can read /
|
|
449
|
-
// override the deploy-defaults. The helper indirection is what
|
|
450
|
-
// config-seed-boot.integration.ts pins — keep it as a single call.
|
|
451
|
-
for (const seed of wiring.shortcutSeeds) {
|
|
452
|
-
await seed(stack.db);
|
|
453
|
-
}
|
|
454
|
-
await applyBootSeeds({
|
|
455
|
-
registry: stack.registry,
|
|
456
|
-
db: stack.db,
|
|
457
|
-
...(bootCrypto.configCipher && { cipher: bootCrypto.configCipher }),
|
|
458
|
-
});
|
|
459
|
-
for (const seed of stackSeeds) {
|
|
460
|
-
await seed(stack);
|
|
461
|
-
}
|
|
462
|
-
},
|
|
463
|
-
}).catch(async (error) => {
|
|
464
|
-
await releasePiiKms().catch(() => { });
|
|
465
|
-
throw error;
|
|
466
|
-
});
|
|
467
|
-
if (options.welcomeBanner) {
|
|
468
|
-
const overrides = typeof options.welcomeBanner === "object" ? options.welcomeBanner : {};
|
|
469
|
-
const port = handle.server?.port ?? options.port ?? 3000;
|
|
470
|
-
const banner = renderWelcomeBanner({
|
|
471
|
-
url: `http://localhost:${port}`,
|
|
472
|
-
...(effectiveAuth?.admin && {
|
|
473
|
-
admin: {
|
|
474
|
-
email: effectiveAuth.admin.email,
|
|
475
|
-
password: effectiveAuth.admin.password,
|
|
476
|
-
},
|
|
477
|
-
}),
|
|
478
|
-
...(overrides.featuresDir !== undefined && { featuresDir: overrides.featuresDir }),
|
|
479
|
-
...(overrides.docsUrl !== undefined && { docsUrl: overrides.docsUrl }),
|
|
480
|
-
});
|
|
481
|
-
// biome-ignore lint/suspicious/noConsole: boot-time UX print, opt-in via welcomeBanner.
|
|
482
|
-
console.log(`\n${banner}\n`);
|
|
483
|
-
}
|
|
484
|
-
return {
|
|
485
|
-
...handle,
|
|
486
|
-
stop: async () => {
|
|
487
|
-
try {
|
|
488
|
-
await handle.stop();
|
|
489
|
-
}
|
|
490
|
-
finally {
|
|
491
|
-
await releasePiiKms();
|
|
492
|
-
}
|
|
493
|
-
},
|
|
494
|
-
};
|
|
495
|
-
}
|
|
496
|
-
const INERT_REVOKER = async () => 0;
|
|
497
|
-
// Binds a late-bound revoker and records how to swap it for an inert one when the app stops.
|
|
498
|
-
function bindReleasable(bind, revoker, inert, resets) {
|
|
499
|
-
if (bind) {
|
|
500
|
-
bind(revoker);
|
|
501
|
-
resets.push(() => bind(inert));
|
|
502
|
-
}
|
|
503
131
|
}
|
|
504
132
|
// Exported for the wiring-contract test (config-resolver-default.integration):
|
|
505
133
|
// pins that the dev configResolver-default carries the ENV→app-override bridge
|
|
@@ -512,10 +140,11 @@ export function mergeConfigResolverDefault(ctx, registry, envSource, cipher) {
|
|
|
512
140
|
...(cipher && { cipher }),
|
|
513
141
|
}),
|
|
514
142
|
};
|
|
515
|
-
// ctx.config
|
|
516
|
-
// addConfigAccessorFactory)
|
|
517
|
-
//
|
|
518
|
-
//
|
|
143
|
+
// Build ctx.config per request through configAccessorFactory (see
|
|
144
|
+
// addConfigAccessorFactory); otherwise
|
|
145
|
+
// ctx.config remains undefined and handlers such as createFileProviderForTenant
|
|
146
|
+
// throw. Build it from the effective resolver so a caller override wins,
|
|
147
|
+
// symmetrically with runProdApp.
|
|
519
148
|
if (ctx === undefined)
|
|
520
149
|
return addConfigAccessorFactory(defaults, registry);
|
|
521
150
|
if (typeof ctx === "function") {
|
|
@@ -25,10 +25,11 @@ export function scaffoldAppFeature(options) {
|
|
|
25
25
|
throw new Error(`scaffoldAppFeature: ${featureDir} already exists — refusing to overwrite`);
|
|
26
26
|
}
|
|
27
27
|
mkdirSync(featureDir, { recursive: true });
|
|
28
|
-
//
|
|
29
|
-
// feature.ts
|
|
30
|
-
//
|
|
31
|
-
//
|
|
28
|
+
// App-feature layout (see bundled-features/tenant):
|
|
29
|
+
// feature.ts only registers the feature; schema/ contains entities; i18n.ts
|
|
30
|
+
// contains server keys; web/ contains the client side; __tests__/ contains
|
|
31
|
+
// the boot test.
|
|
32
|
+
// handlers/ and lib/ are created when the first handler or logic module is added.
|
|
32
33
|
mkdirSync(join(featureDir, "schema"), { recursive: true });
|
|
33
34
|
mkdirSync(join(featureDir, "web"), { recursive: true });
|
|
34
35
|
mkdirSync(join(featureDir, "__tests__"), { recursive: true });
|