@cosmicdrift/kumiko-dev-server 0.387.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.
Files changed (50) hide show
  1. package/dist/build-server-bundle.d.ts +1 -1
  2. package/dist/build-server-bundle.js +2 -2
  3. package/dist/create-kumiko-server-client-bundles.d.ts +21 -0
  4. package/dist/create-kumiko-server-client-bundles.js +97 -0
  5. package/dist/create-kumiko-server-html.d.ts +21 -0
  6. package/dist/create-kumiko-server-html.js +132 -0
  7. package/dist/create-kumiko-server-public-files.d.ts +6 -0
  8. package/dist/create-kumiko-server-public-files.js +96 -0
  9. package/dist/create-kumiko-server-reload.d.ts +14 -0
  10. package/dist/create-kumiko-server-reload.js +73 -0
  11. package/dist/create-kumiko-server-routes.d.ts +16 -0
  12. package/dist/create-kumiko-server-routes.js +206 -0
  13. package/dist/create-kumiko-server-shutdown.d.ts +14 -0
  14. package/dist/create-kumiko-server-shutdown.js +72 -0
  15. package/dist/create-kumiko-server-stack.d.ts +12 -0
  16. package/dist/create-kumiko-server-stack.js +102 -0
  17. package/dist/create-kumiko-server-stylesheet.d.ts +10 -0
  18. package/dist/create-kumiko-server-stylesheet.js +135 -0
  19. package/dist/create-kumiko-server-watcher.d.ts +22 -0
  20. package/dist/create-kumiko-server-watcher.js +155 -0
  21. package/dist/create-kumiko-server.d.ts +6 -20
  22. package/dist/create-kumiko-server.js +59 -1046
  23. package/dist/dev-server-runtime.d.ts +5 -0
  24. package/dist/dev-server-runtime.js +11 -0
  25. package/dist/framework-version.js +2 -1
  26. package/dist/index.d.ts +3 -1
  27. package/dist/index.js +3 -1
  28. package/dist/managed-files/app-jobs.js +1 -1
  29. package/dist/managed-files/check.js +1 -1
  30. package/dist/managed-files/registry.js +5 -1
  31. package/dist/run-dev-app-auth.d.ts +38 -0
  32. package/dist/run-dev-app-auth.js +156 -0
  33. package/dist/run-dev-app-config.d.ts +26 -0
  34. package/dist/run-dev-app-config.js +185 -0
  35. package/dist/run-dev-app-server-options.d.ts +13 -0
  36. package/dist/run-dev-app-server-options.js +49 -0
  37. package/dist/run-dev-app.d.ts +11 -7
  38. package/dist/run-dev-app.js +90 -463
  39. package/dist/scaffold-app.d.ts +2 -1
  40. package/dist/scaffold-app.js +29 -2
  41. package/dist/schema-apply.d.ts +1 -1
  42. package/dist/schema-apply.js +4 -4
  43. package/dist/setup-test-stack-from-features.d.ts +1 -0
  44. package/dist/setup-test-stack-from-features.js +6 -2
  45. package/dist/write-server-lockfile.js +23 -16
  46. package/package.json +15 -10
  47. package/src/changes.json +61 -0
  48. package/templates/deploy/Dockerfile.template +3 -2
  49. package/templates/managed/ci-public.yml.template +6 -2
  50. package/templates/managed/kumiko-schema.ts.template +1 -0
@@ -11,222 +11,103 @@
11
11
  // For maximum control, such as custom auth wiring, a different membership
12
12
  // query, or a custom LoginRateLimiter, use `createKumikoServer` directly from
13
13
  // @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";
18
- 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";
14
+ import { createConfigResolver } from "@cosmicdrift/kumiko-bundled-features/config";
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, buildConfigOverrides, loggerFromExtraContext, } from "@cosmicdrift/kumiko-server-runtime/run-prod-app";
39
23
  export async function runDevApp(options) {
40
- // Automatically mixes standard features in auth mode through composeFeatures.
41
- // runProdApp and the per-app Drizzle schema generator use the same helper,
42
- // keeping migrations and runtime feature composition aligned.
43
- const rawEnvSource = options.envSource ?? process.env;
44
- const seedsConfig = options.seeds !== undefined && !isSeedFnList(options.seeds) ? options.seeds : undefined;
45
- const stackSeeds = options.seeds === undefined
46
- ? []
47
- : isSeedFnList(options.seeds)
48
- ? options.seeds
49
- : (options.seeds.run ?? []);
50
- if (options.envSchema) {
51
- // Free dev seeds take the test stack; the env blocks only read the declarative part.
52
- const { seeds: _devSeeds, kms: devKms, ...bootFields } = options;
53
- const composed = withBootOptionEnvBlocks(options.envSchema, {
54
- ...bootFields,
55
- ...(devKms && { kms: devKms }),
56
- ...(seedsConfig && { seeds: { ...seedsConfig, run: [] } }),
57
- features: options.features,
58
- });
59
- parseEnv(composed.schema, rawEnvSource, { sources: composed.sources });
24
+ const bootFailure = { release: undefined };
25
+ try {
26
+ return await bootDevApp(options, bootFailure);
60
27
  }
61
- rejectRemovedEffectiveFeatures(options);
62
- const wiring = await resolveBootOptionWiring({
63
- features: options.features,
64
- ...(options.mail && { mail: options.mail }),
65
- ...(options.fileStorage && { fileStorage: options.fileStorage }),
66
- ...(options.kmsMode !== undefined && { kmsMode: options.kmsMode }),
67
- ...(options.billing && { billing: options.billing }),
68
- ...(options.featureToggles && { featureToggles: true }),
69
- ...(options.demoMode && { demoMode: options.demoMode }),
70
- ...(seedsConfig && { seeds: { ...seedsConfig, run: [] } }),
71
- ...(options.kms && { kms: options.kms }),
72
- ...(options.blindIndexKey !== undefined && { blindIndexKey: options.blindIndexKey }),
73
- ...(options.auth && { auth: options.auth }),
74
- }, rawEnvSource);
75
- const envSource = Object.keys(wiring.envDefaults).length > 0
76
- ? { ...wiring.envDefaults, ...rawEnvSource }
77
- : rawEnvSource;
78
- assertStackBootRequirements({
79
- features: options.features,
80
- ...(options.featureToggles && { featureToggles: true }),
81
- }, ["featureToggles"]);
82
- // Normalizes auth.mail into the explicit flow fields, symmetrically with
83
- // runProdApp. hmacSecret is read from the JWT_SECRET environment value and
84
- // fails fast when unavailable. From this point on, use effectiveAuth rather
85
- // than 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
- // Run boot validation before creating the server. This catches the same
124
- // errors runProdApp catches (unqualified navigation or handler QNs,
125
- // screen-access violations), which would otherwise only show up as a prod pod
126
- // in CrashLoopBackOff. It throws
127
- // synchronously before a socket is opened.
128
- // Resolve this before validateBoot: an app-supplied masterKey provider must
129
- // satisfy the encrypted-field gate 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
- // 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
- // The real trialGate lands on holder.resolver only after plugin.build(). Delegate rather than
159
- // copy it because this wrapper closure is created before build().
160
- // Return false when no trial gate is available.
161
- trialGate: (tenantId, featureName) => tierResolverHolder.resolver?.trialGate?.(tenantId, featureName) ??
162
- Promise.resolve(false),
163
- })
164
- : undefined;
165
- // Provide the configResolver default for the config feature in auth mode;
166
- // in no-auth mode, leave that choice to the caller. Wrap the factory so the
167
- // spread applies to its result; spreading the function itself is a no-op. The
168
- // ENV-to-config bridge supplies app-override defaults from `env:` keys.
169
- // The temporary registry extracts only config keys because the resolver is
170
- // built before server boot, and stack.registry only exists from onAfterSetup;
171
- // createKumikoServer builds its own registry internally.
172
- // The app-wide cipher for `encrypted: true` entity fields is resolved lazily
173
- // by executors, symmetrically with runProdApp.
174
- configureEntityFieldEncryption(bootCrypto.entityFieldCipher);
175
- // Configure Subject-KMS and blind-index handling, symmetrically with
176
- // runProdApp. Dev warns instead of failing for plaintext PII, but kms with
177
- // lookupable fields still requires blindIndexKey because lookups would fail
178
- // in every mode.
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. 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
- ? mergeConfigResolverDefault(options.extraContext, registry, envSource, bootCrypto.configCipher)
108
+ ? mergeConfigResolverDefault(options.extraContext, registry, envSource, bootCrypto.configCipher, options.appOverrides)
222
109
  : options.extraContext;
223
- // Automatically wire templateResolver and feature-gated secrets, symmetrically
224
- // with runProdApp. Unlike prod, the database is available here only in the
225
- // factory dependencies because createKumikoServer builds the stack. Therefore
226
- // use a factory: buildBootExtraContext runs with hasAuth:false because
227
- // configResolver already comes from cfgExtra, and cfgExtra values override
228
- // the boot defaults.
229
- const extraContext = (deps) => {
110
+ return (deps) => {
230
111
  const base = typeof cfgExtra === "function" ? cfgExtra(deps) : (cfgExtra ?? {});
231
112
  const bootLogger = loggerFromExtraContext(base);
232
113
  const boot = buildBootExtraContext({
@@ -247,269 +128,15 @@ export async function runDevApp(options) {
247
128
  ...base,
248
129
  };
249
130
  };
250
- // Sessions: late-bound holder — resolveSessionStore needs concrete db+
251
- // registry (only after setupTestStack). Wired when sessions feature is
252
- // mounted; no auth.sessions opt-in anymore.
253
- let sessionStore;
254
- const requireSessionStore = () => {
255
- if (!sessionStore) {
256
- throw new Error("[runDevApp] sessionStore accessed before onAfterSetup");
257
- }
258
- return sessionStore;
259
- };
260
- // Token-verifier opt-in: same late-bound holder pattern — resolveTokenVerifier
261
- // needs the real db+registry (only concrete after setupTestStack). Wired
262
- // whenever a provider feature (personal-access-tokens today) is mounted.
263
- let tokenVerifier;
264
- const patFeature = features.find((f) => f.name === PAT_FEATURE);
265
- const mfaFeature = features.find((f) => f.name === AUTH_MFA_FEATURE);
266
- // Same master secret as runProdApp. Without JWT_SECRET the feature keeps
267
- // its explicit secrets, or its handlers report the missing binding.
268
- const devMasterSecret = envSource["JWT_SECRET"];
269
- if (mfaFeature && devMasterSecret) {
270
- bindMfaTokenSecretsFromFeature(mfaFeature)?.(devMasterSecret, envSource);
271
- }
272
- if (effectiveAuth &&
273
- mfaFeature &&
274
- registry?.getWriteHandler(AuthHandlers.switchTenantMfaGate) === undefined) {
275
- throw new Error(`[runDevApp] auth-mfa is mounted but ${AuthHandlers.switchTenantMfaGate} is not registered: ` +
276
- "createAuthEmailPasswordFeature registers it only when mfaStatusChecker is set, and without it " +
277
- "POST /auth/switch-tenant would mint sessions without the second factor");
278
- }
279
- const patAuthFragment = patFeature
280
- ? {
281
- tokenVerifier: (rawToken) => {
282
- if (!tokenVerifier) {
283
- throw new Error("[runDevApp] token-verifier accessed before onAfterSetup");
284
- }
285
- return tokenVerifier(rawToken);
286
- },
287
- patRateLimiter: (() => {
288
- const rl = patRateLimitFromFeature(patFeature);
289
- return createInMemoryLoginRateLimiter(rl.maxRequests, rl.windowMs);
290
- })(),
291
- }
292
- : {};
293
- // Parity with runProdApp: gate on sessionStore provider, not
294
- // the sessions feature name — a custom provider must also wire auth
295
- // callbacks. sessionStoreProviderMounted computed above via the registry
296
- // (same check as runProdApp) — not recomputed here.
297
- const sessionAuthFragment = effectiveAuth && sessionStoreProviderMounted
298
- ? {
299
- sessionCreator: (user, meta) => requireSessionStore().creator(user, meta),
300
- sessionRevoker: (sid) => requireSessionStore().revoker(sid),
301
- sessionChecker: (sid, userId) => requireSessionStore().checker(sid, userId),
302
- }
303
- : {};
304
- const trustedProxyHops = options.trustedProxyHops ??
305
- effectiveAuth?.trustedProxyHops ??
306
- parseTrustedProxyHopsEnv(envSource[TRUSTED_PROXY_HOPS_ENV], "runDevApp");
307
- const handle = await createKumikoServer({
308
- features,
309
- ...(options.clientEntry !== undefined && { clientEntry: options.clientEntry }),
310
- ...(options.errorDocs && { errorDocs: options.errorDocs }),
311
- ...(options.clientEntries !== undefined && { clientEntries: options.clientEntries }),
312
- ...(options.hostDispatch !== undefined && { hostDispatch: options.hostDispatch }),
313
- ...(options.resolvePageHead !== undefined && { resolvePageHead: options.resolvePageHead }),
314
- ...(options.stylesheet !== undefined && { stylesheet: options.stylesheet }),
315
- ...(options.stylesheetWatch !== undefined && { stylesheetWatch: options.stylesheetWatch }),
316
- ...(options.htmlPath !== undefined && { htmlPath: options.htmlPath }),
317
- ...(options.port !== undefined && { port: options.port }),
318
- ...(options.watchDirs !== undefined && { watchDirs: options.watchDirs }),
319
- ...(options.installSignalHandlers !== undefined && {
320
- installSignalHandlers: options.installSignalHandlers,
321
- }),
322
- ...(extraContext !== undefined && { extraContext }),
323
- ...(options.anonymousAccess !== undefined && { anonymousAccess: options.anonymousAccess }),
324
- ...(options.files !== undefined && { files: options.files }),
325
- ...(extraRoutes !== undefined && { extraRoutes }),
326
- ...(options.wire !== undefined && { wire: options.wire }),
327
- ...(trustedProxyHops !== undefined && { trustedProxyHops }),
328
- ...(options.demoMode && { wrapFetch: wiring.wrapFetch }),
329
- ...(finalEffectiveFeatures !== undefined && {
330
- effectiveFeatures: finalEffectiveFeatures,
331
- }),
332
- ...(effectiveAuth && {
333
- auth: {
334
- membershipQuery: TenantQueries.memberships,
335
- loginHandler: AuthHandlers.login,
336
- loginErrorStatusMap: effectiveAuth.loginErrorStatusMap ?? {
337
- [AuthErrors.invalidCredentials]: 401,
338
- [AuthErrors.noMembership]: 403,
339
- },
340
- ...(effectiveAuth.cookieDomain !== undefined && {
341
- cookieDomain: effectiveAuth.cookieDomain,
342
- }),
343
- ...(effectiveAuth.retiredCookieDomains !== undefined && {
344
- retiredCookieDomains: effectiveAuth.retiredCookieDomains,
345
- }),
346
- ...(effectiveAuth.allowedOrigins !== undefined && {
347
- allowedOrigins: effectiveAuth.allowedOrigins,
348
- }),
349
- ...(effectiveAuth.unsafeSkipOriginCheck !== undefined && {
350
- unsafeSkipOriginCheck: effectiveAuth.unsafeSkipOriginCheck,
351
- }),
352
- ...(effectiveAuth.trustedProxyHops !== undefined && {
353
- trustedProxyHops: effectiveAuth.trustedProxyHops,
354
- }),
355
- ...(effectiveAuth.postAuthLanding !== undefined && {
356
- postAuthLanding: effectiveAuth.postAuthLanding,
357
- }),
358
- ...sessionAuthFragment,
359
- ...patAuthFragment,
360
- ...(mfaFeature && {
361
- mfaVerifyHandler: AuthMfaHandlers.verify,
362
- switchTenantMfaGateHandler: AuthHandlers.switchTenantMfaGate,
363
- mfaPreauthEnableStartHandler: AuthMfaHandlers.enableStartPreauth,
364
- mfaPreauthConfirmHandler: AuthMfaHandlers.enableConfirmPreauth,
365
- }),
366
- ...(effectiveAuth.passwordReset && {
367
- passwordReset: {
368
- requestHandler: AuthHandlers.requestPasswordReset,
369
- confirmHandler: AuthHandlers.resetPassword,
370
- },
371
- }),
372
- ...(effectiveAuth.emailVerification && {
373
- emailVerification: {
374
- requestHandler: AuthHandlers.requestEmailVerification,
375
- confirmHandler: AuthHandlers.verifyEmail,
376
- },
377
- }),
378
- ...(effectiveAuth.accountUnlock && {
379
- accountUnlock: {
380
- requestHandler: AuthHandlers.requestAccountUnlock,
381
- confirmHandler: AuthHandlers.confirmAccountUnlock,
382
- },
383
- }),
384
- ...(effectiveAuth.signup && {
385
- signup: {
386
- requestHandler: AuthHandlers.signupRequest,
387
- confirmHandler: AuthHandlers.signupConfirm,
388
- },
389
- }),
390
- ...(effectiveAuth.invite && {
391
- invite: {
392
- acceptHandler: AuthHandlers.inviteAccept,
393
- acceptWithLoginHandler: AuthHandlers.inviteAcceptWithLogin,
394
- signupCompleteHandler: AuthHandlers.inviteSignupComplete,
395
- infoHandler: AuthQueries.inviteInfo,
396
- },
397
- }),
398
- },
399
- }),
400
- onAfterSetup: async (stack) => {
401
- // Build tier-resolver BEFORE any seeds so seeds can rely
402
- // on the resolver being live (e.g. seed that writes a SystemAdmin's
403
- // tier-assignment can immediately read tier-cuts).
404
- if (tierResolverUsage) {
405
- tierResolverHolder.resolver = await requireTierResolverPlugin(tierResolverUsage).build({
406
- db: stack.db,
407
- registry: stack.registry,
408
- cacheSync: stack.cacheSync,
409
- });
410
- }
411
- if (wiring.featureToggles) {
412
- const bound = await wiring.featureToggles.bind({
413
- db: stack.db,
414
- registry: stack.registry,
415
- cacheSync: stack.cacheSync,
416
- tierResolver: tierResolverHolder.resolver,
417
- });
418
- tierResolverHolder.resolver = bound.resolver;
419
- }
420
- if (effectiveAuth && stack.registry.getExtensionUsages(EXT_SESSION_STORE).length > 0) {
421
- // Secure-by-default: resolve sessionStore provider; bind
422
- // password-change mass-revoke + MFA revokeAllOthers.
423
- const store = await resolveSessionStore({
424
- db: stack.db,
425
- registry: stack.registry,
426
- });
427
- const sessionsFeature = features.find((f) => f.name === SESSIONS_FEATURE);
428
- if (sessionsFeature) {
429
- bindReleasable(bindAutoRevokeFromFeature(sessionsFeature), store.massRevoker, INERT_REVOKER, resetLateBindings);
430
- }
431
- if (mfaFeature) {
432
- bindReleasable(bindMfaRevokeAllOtherSessionsFromFeature(mfaFeature), store.revokeAllOthers, INERT_REVOKER, resetLateBindings);
433
- }
434
- if (mfaFeature && patFeature) {
435
- bindReleasable(bindRevokeAllPatTokensFromFeature(mfaFeature), (userId) => revokeAllPatTokensForUser(stack.db, userId), INERT_REVOKER, resetLateBindings);
436
- }
437
- sessionStore = store;
438
- }
439
- if (patFeature) {
440
- tokenVerifier = (rawToken) => resolveTokenVerifier({ db: stack.db, registry: stack.registry }, rawToken);
441
- // Password-change → PAT-revoke: independent of sessions/MFA being
442
- // mounted, so wired unconditionally here whenever PAT itself is.
443
- bindReleasable(bindPatAutoRevokeOnPasswordChangeFromFeature(patFeature), (userId) => revokeAllPatTokensForUser(stack.db, userId), INERT_REVOKER, resetLateBindings);
444
- }
445
- if (effectiveAuth) {
446
- await seedAdminGuarded(stack.db, effectiveAuth.admin);
447
- }
448
- // Apply r.config({ seeds }) declared by any registered feature.
449
- // Runs before user-supplied seed callbacks so those can read /
450
- // override the deploy-defaults. The helper indirection is what
451
- // config-seed-boot.integration.ts pins — keep it as a single call.
452
- for (const seed of wiring.shortcutSeeds) {
453
- await seed(stack.db);
454
- }
455
- await applyBootSeeds({
456
- registry: stack.registry,
457
- db: stack.db,
458
- ...(bootCrypto.configCipher && { cipher: bootCrypto.configCipher }),
459
- });
460
- for (const seed of stackSeeds) {
461
- await seed(stack);
462
- }
463
- },
464
- }).catch(async (error) => {
465
- await releasePiiKms().catch(() => { });
466
- throw error;
467
- });
468
- if (options.welcomeBanner) {
469
- const overrides = typeof options.welcomeBanner === "object" ? options.welcomeBanner : {};
470
- const port = handle.server?.port ?? options.port ?? 3000;
471
- const banner = renderWelcomeBanner({
472
- url: `http://localhost:${port}`,
473
- ...(effectiveAuth?.admin && {
474
- admin: {
475
- email: effectiveAuth.admin.email,
476
- password: effectiveAuth.admin.password,
477
- },
478
- }),
479
- ...(overrides.featuresDir !== undefined && { featuresDir: overrides.featuresDir }),
480
- ...(overrides.docsUrl !== undefined && { docsUrl: overrides.docsUrl }),
481
- });
482
- // biome-ignore lint/suspicious/noConsole: boot-time UX print, opt-in via welcomeBanner.
483
- console.log(`\n${banner}\n`);
484
- }
485
- return {
486
- ...handle,
487
- stop: async () => {
488
- try {
489
- await handle.stop();
490
- }
491
- finally {
492
- await releasePiiKms();
493
- }
494
- },
495
- };
496
- }
497
- const INERT_REVOKER = async () => 0;
498
- // Binds a late-bound revoker and records how to swap it for an inert one when the app stops.
499
- function bindReleasable(bind, revoker, inert, resets) {
500
- if (bind) {
501
- bind(revoker);
502
- resets.push(() => bind(inert));
503
- }
504
131
  }
505
132
  // Exported for the wiring-contract test (config-resolver-default.integration):
506
133
  // pins that the dev configResolver-default carries the ENV→app-override bridge
507
134
  // and that a caller-supplied configResolver still overrides it. Not re-exported
508
135
  // from the package barrel (index.ts lists only runDevApp).
509
- export function mergeConfigResolverDefault(ctx, registry, envSource, cipher) {
136
+ export function mergeConfigResolverDefault(ctx, registry, envSource, cipher, appOverrides) {
510
137
  const defaults = {
511
138
  configResolver: createConfigResolver({
512
- appOverrides: buildEnvConfigOverrides(registry, envSource),
139
+ appOverrides: buildConfigOverrides(registry, envSource, appOverrides),
513
140
  ...(cipher && { cipher }),
514
141
  }),
515
142
  };
@@ -27,7 +27,8 @@ export type ScaffoldAppOptions = {
27
27
  * (the CLI's ctx.cwd) MUST pass it so the scaffold lands where the
28
28
  * command's output claims it does. */
29
29
  readonly cwd?: string;
30
- /** npm-version-pin for @cosmicdrift/* deps. Default "*" for latest. */
30
+ /** Exact version pin for @cosmicdrift/* deps. Default: the running framework version
31
+ * (resolveFrameworkVersion); scaffoldApp throws when that cannot be resolved. */
31
32
  readonly frameworkVersion?: string;
32
33
  /** Bundled-features to mount in run-config.ts. Default: secrets + auth-foundation + sessions
33
34
  * (the historical foundation). create-kumiko-app passes the picker output