@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.
Files changed (56) hide show
  1. package/LICENSE +21 -57
  2. package/dist/build-server-bundle.d.ts +7 -7
  3. package/dist/build-server-bundle.js +53 -53
  4. package/dist/build.js +3 -3
  5. package/dist/compose-stacks.d.ts +2 -2
  6. package/dist/compose-stacks.js +1 -2
  7. package/dist/crash-tracker.d.ts +3 -7
  8. package/dist/crash-tracker.js +4 -13
  9. package/dist/create-kumiko-server-client-bundles.d.ts +21 -0
  10. package/dist/create-kumiko-server-client-bundles.js +97 -0
  11. package/dist/create-kumiko-server-html.d.ts +21 -0
  12. package/dist/create-kumiko-server-html.js +132 -0
  13. package/dist/create-kumiko-server-public-files.d.ts +6 -0
  14. package/dist/create-kumiko-server-public-files.js +96 -0
  15. package/dist/create-kumiko-server-reload.d.ts +14 -0
  16. package/dist/create-kumiko-server-reload.js +73 -0
  17. package/dist/create-kumiko-server-routes.d.ts +16 -0
  18. package/dist/create-kumiko-server-routes.js +206 -0
  19. package/dist/create-kumiko-server-shutdown.d.ts +14 -0
  20. package/dist/create-kumiko-server-shutdown.js +72 -0
  21. package/dist/create-kumiko-server-stack.d.ts +12 -0
  22. package/dist/create-kumiko-server-stack.js +101 -0
  23. package/dist/create-kumiko-server-stylesheet.d.ts +10 -0
  24. package/dist/create-kumiko-server-stylesheet.js +137 -0
  25. package/dist/create-kumiko-server-watcher.d.ts +22 -0
  26. package/dist/create-kumiko-server-watcher.js +155 -0
  27. package/dist/create-kumiko-server.d.ts +53 -71
  28. package/dist/create-kumiko-server.js +59 -1076
  29. package/dist/dev-server-runtime.d.ts +5 -0
  30. package/dist/dev-server-runtime.js +11 -0
  31. package/dist/env-schema.d.ts +1 -1
  32. package/dist/env-schema.js +4 -4
  33. package/dist/index.d.ts +3 -1
  34. package/dist/index.js +17 -15
  35. package/dist/managed-files/app-jobs.js +1 -1
  36. package/dist/managed-files/check.js +1 -1
  37. package/dist/run-dev-app-auth.d.ts +38 -0
  38. package/dist/run-dev-app-auth.js +155 -0
  39. package/dist/run-dev-app-config.d.ts +26 -0
  40. package/dist/run-dev-app-config.js +185 -0
  41. package/dist/run-dev-app-server-options.d.ts +13 -0
  42. package/dist/run-dev-app-server-options.js +49 -0
  43. package/dist/run-dev-app.d.ts +52 -50
  44. package/dist/run-dev-app.js +100 -471
  45. package/dist/scaffold-app-feature.js +5 -4
  46. package/dist/scaffold-app.js +5 -7
  47. package/dist/schema-apply.d.ts +3 -3
  48. package/dist/schema-apply.js +16 -16
  49. package/dist/schema-tables-auth-mode.js +16 -15
  50. package/dist/schema-tables-minimal.js +7 -7
  51. package/dist/setup-test-stack-from-features.js +1 -1
  52. package/dist/welcome-banner.js +2 -2
  53. package/dist/write-server-lockfile.js +23 -16
  54. package/package.json +10 -10
  55. package/src/changes.json +30 -0
  56. package/templates/managed/ci-public.yml.template +4 -0
@@ -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, cutting a sample bootstrap from ~50 lines to 5-10.
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-mix passiert NUR im auth-mode. Ohne `auth`-Block bleibt der
7
- // Server im Auto-Mint-JWT-Modus (Dev-Default) und mischt nichts dazu —
8
- // Showcases die nur eine Domain demonstrieren wollen ohne Login-Flow
9
- // haben so nicht plötzlich vier zusätzliche Features in der Registry.
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
- // Wer maximale Kontrolle braucht (z.B. abweichende Auth-Wiring,
12
- // alternativer Membership-Query, eigener LoginRateLimiter): geht direkt
13
- // auf `createKumikoServer` aus @cosmicdrift/kumiko-dev-server.
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 { bindPatAutoRevokeOnPasswordChangeFromFeature, PAT_FEATURE, patRateLimitFromFeature, revokeAllPatTokensForUser, } from "@cosmicdrift/kumiko-bundled-features/personal-access-tokens";
20
- import { bindAutoRevokeFromFeature, SESSIONS_FEATURE, } from "@cosmicdrift/kumiko-bundled-features/sessions";
21
- import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
22
- import { createInMemoryLoginRateLimiter, parseTrustedProxyHopsEnv, TRUSTED_PROXY_HOPS_ENV, } from "@cosmicdrift/kumiko-framework/api";
23
- import { configureBlindIndexKey, configurePiiSubjectKms, } from "@cosmicdrift/kumiko-framework/crypto";
24
- import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
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, requireEnv, resolveAuthMail, resolveUnsubscribeWiring, } from "@cosmicdrift/kumiko-server-runtime/run-prod-app";
22
+ import { addConfigAccessorFactory, buildBootExtraContext, loggerFromExtraContext, } from "@cosmicdrift/kumiko-server-runtime/run-prod-app";
39
23
  export async function runDevApp(options) {
40
- // Auto-mix Standard-Features im auth-mode via composeFeatures (single
41
- // source of truth — auch runProdApp und der per-app drizzle-Schema-
42
- // Generator nutzen denselben Helper, damit Migration und Runtime nie
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
- rejectRemovedEffectiveFeatures(options);
63
- const wiring = await resolveBootOptionWiring({
64
- features: options.features,
65
- ...(options.mail && { mail: options.mail }),
66
- ...(options.fileStorage && { fileStorage: options.fileStorage }),
67
- ...(options.kmsMode !== undefined && { kmsMode: options.kmsMode }),
68
- ...(options.billing && { billing: options.billing }),
69
- ...(options.featureToggles && { featureToggles: true }),
70
- ...(options.demoMode && { demoMode: options.demoMode }),
71
- ...(seedsConfig && { seeds: { ...seedsConfig, run: [] } }),
72
- ...(options.kms && { kms: options.kms }),
73
- ...(options.blindIndexKey !== undefined && { blindIndexKey: options.blindIndexKey }),
74
- ...(options.auth && { auth: options.auth }),
75
- }, rawEnvSource);
76
- const envSource = Object.keys(wiring.envDefaults).length > 0
77
- ? { ...wiring.envDefaults, ...rawEnvSource }
78
- : rawEnvSource;
79
- assertStackBootRequirements({
80
- features: options.features,
81
- ...(options.featureToggles && { featureToggles: true }),
82
- }, ["featureToggles"]);
83
- // auth.mail → normalisiert in die expliziten Flow-Felder (symmetrisch zu
84
- // runProdApp). hmacSecret = JWT_SECRET-env (fail-fast, symmetrisch zu
85
- // runProdApp). Ab hier IMMER effectiveAuth statt options.auth.
86
- const effectiveAuth = options.auth
87
- ? resolveAuthMail(options.auth, requireEnv("JWT_SECRET", envSource, "runDevApp"), envSource, {
88
- mailConfigured: wiring.mailConfigured,
89
- })
90
- : undefined;
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
- warnIfNonUtcServerTimeZone();
136
- validateAppCustomScreenWriteQns(process.cwd(), collectWriteHandlerQns(features));
137
- // Sprint-8a Tier-Composition auto-wire: scan features for a
138
- // tenantTierResolver-extension and wire a late-bound wrapper here; it is
139
- // filled in onAfterSetup (where stack.db + stack.registry are available).
140
- // The global operator toggles layer over the same wrapper.
141
- // App-Author sees nothing — `createTierEngineFeature(opts)` mounts +
142
- // framework auto-wires.
143
- const tierResolverUsage = findTierResolverUsage(features);
144
- const tierResolverHolder = {
145
- resolver: undefined,
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
- return tierResolverHolder.resolver(tenantId);
157
- }, {
158
- // Late-bind: der echte trialGate hängt erst nach plugin.build() am
159
- // holder.resolver. Delegieren (nicht kopieren), weil dieses
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
- assertPiiBootInvariants(features, {
201
- kms: piiKms.kms,
202
- blindIndexKey: piiKms.blindIndexKey,
203
- mode: "dev",
204
- });
205
- if (piiKms.mode === "ephemeral-default") {
206
- logInfo("[runDevApp] PII encrypted with in-memory KMS (ephemeral DB); pass kms: false to opt out");
207
- }
208
- // Throwaway registry, built once and reused below — mirrors runProdApp's
209
- // `createRegistry(features)` right after validateBoot (#2027). Symmetric
210
- // sessionStoreProviderMounted check to runProdApp's (registry.getExtension-
211
- // Usages, not a raw features.some(...) scan) so dev and prod never ask the
212
- // same question two different ways again.
213
- const registry = effectiveAuth ? createRegistry(features) : undefined;
214
- const sessionStoreProviderMounted = registry !== undefined && registry.getExtensionUsages(EXT_SESSION_STORE).length > 0;
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
- // Auto-wire templateResolver (immer) + secrets (feature-gated), symmetrisch zu
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 wird per-Request aus configAccessorFactory geminted (siehe
516
- // addConfigAccessorFactory) — sonst bleibt ctx.config undefined und Handler
517
- // die es lesen (createFileProviderForTenant) werfen. Aus dem EFFEKTIVEN
518
- // Resolver (Caller-Override gewinnt) gebaut, symmetrisch zu runProdApp.
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
- // Volle App-Feature-Konvention (Referenz: bundled-features/tenant):
29
- // feature.ts = nur Registrierung, schema/ für Entities, i18n.ts für
30
- // Server-Keys, web/ für die Client-Seite, __tests__/ mit Boot-Test.
31
- // handlers/ + lib/ entstehen beim ersten echten Handler/Logik-Modul.
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 });