@cosmicdrift/kumiko-dev-server 0.387.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/dist/build-server-bundle.d.ts +1 -1
- package/dist/build-server-bundle.js +2 -2
- 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 +6 -20
- package/dist/create-kumiko-server.js +59 -1046
- package/dist/dev-server-runtime.d.ts +5 -0
- package/dist/dev-server-runtime.js +11 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- 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 +4 -4
- package/dist/run-dev-app.js +86 -459
- package/dist/schema-apply.d.ts +1 -1
- package/dist/schema-apply.js +4 -4
- package/dist/write-server-lockfile.js +23 -16
- package/package.json +9 -9
- package/src/changes.json +30 -0
- package/templates/managed/ci-public.yml.template +4 -0
package/dist/run-dev-app.js
CHANGED
|
@@ -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
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
|
-
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
// 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
|
-
|
|
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
|
-
// 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,260 +128,6 @@ 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
|
package/dist/schema-apply.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ export type SchemaApplyOptions = ComposeFeaturesOptions & {
|
|
|
4
4
|
/** App features (e.g. APP_FEATURES from run-config), composed with the
|
|
5
5
|
* bundled features for the projection rebuild registry. */
|
|
6
6
|
readonly features: readonly FeatureDefinition[];
|
|
7
|
-
/** Default INIT_CWD ?? process.cwd();
|
|
7
|
+
/** Default INIT_CWD ?? process.cwd(); migrations live under <appCwd>/kumiko/migrations. */
|
|
8
8
|
readonly appCwd?: string;
|
|
9
9
|
};
|
|
10
10
|
export declare function runSchemaApply(opts: SchemaApplyOptions): Promise<number>;
|
package/dist/schema-apply.js
CHANGED
|
@@ -22,7 +22,7 @@ export async function runSchemaApply(opts) {
|
|
|
22
22
|
const appCwd = opts.appCwd ?? process.env["INIT_CWD"] ?? process.cwd();
|
|
23
23
|
const migrationsDir = join(appCwd, "kumiko/migrations");
|
|
24
24
|
if (!existsSync(migrationsDir)) {
|
|
25
|
-
console.error(`\n ${migrationsDir}
|
|
25
|
+
console.error(`\n ${migrationsDir} is missing — kumiko/migrations/ must be part of the image.\n`);
|
|
26
26
|
return 1;
|
|
27
27
|
}
|
|
28
28
|
const { db, close } = createDbConnection({ url: dbUrl });
|
|
@@ -32,11 +32,11 @@ export async function runSchemaApply(opts) {
|
|
|
32
32
|
// on an empty Greenfield DB they create kumiko_events/_consumers/_projections
|
|
33
33
|
// before the app migrations run. Without this, an empty DB (e.g. cashcolt
|
|
34
34
|
// on a fresh CNPG) fails with `relation "kumiko_events" does not exist`.
|
|
35
|
-
console.log("\n
|
|
35
|
+
console.log("\n Creating framework infrastructure tables (idempotent)…");
|
|
36
36
|
await createEventsTable(db);
|
|
37
37
|
await createEventConsumerStateTable(db);
|
|
38
38
|
await createProjectionStateTable(db);
|
|
39
|
-
console.log(`
|
|
39
|
+
console.log(` Applying kumiko migrations (${migrationsDir})…`);
|
|
40
40
|
const result = await runMigrationsFromDir(db, migrationsDir);
|
|
41
41
|
if (result.applied.length === 0) {
|
|
42
42
|
console.log(`\n ✓ All ${result.skipped.length} migrations already applied.\n`);
|
|
@@ -79,6 +79,6 @@ export async function runStandaloneSchemaCli(opts) {
|
|
|
79
79
|
if (cmd === "schema" && sub === "apply") {
|
|
80
80
|
process.exit(await runSchemaApply(opts));
|
|
81
81
|
}
|
|
82
|
-
console.error(`\n
|
|
82
|
+
console.error(`\n Unknown command: kumiko ${cmd ?? ""} ${sub ?? ""}\n The standalone bundle only supports 'kumiko schema apply'.\n`);
|
|
83
83
|
process.exit(1);
|
|
84
84
|
}
|