@cosmicdrift/kumiko-dev-server 0.113.1 → 1.0.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/package.json +3 -3
- package/src/__tests__/compose-features.test.ts +6 -5
- package/src/__tests__/schema-apply.integration.test.ts +2 -5
- package/src/boot/apply-boot-seeds.ts +3 -4
- package/src/run-dev-app.ts +10 -74
- package/src/run-prod-app.ts +46 -79
- package/src/scaffold-app.ts +7 -8
- package/src/schema-apply.ts +1 -10
- package/src/welcome-banner.ts +4 -1
- package/src/boot/boot-crypto.ts +0 -82
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-dev-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "Development server bootstrap for Kumiko apps. Bundles the client, mints dev-JWTs, injects the resolved AppSchema, and seeds an admin. Not for production.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
"kumiko-schema-check": "./bin/kumiko-schema-check.ts"
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@cosmicdrift/kumiko-bundled-features": "0.
|
|
54
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
53
|
+
"@cosmicdrift/kumiko-bundled-features": "1.0.0",
|
|
54
|
+
"@cosmicdrift/kumiko-framework": "1.0.0",
|
|
55
55
|
"ts-morph": "^28.0.0"
|
|
56
56
|
},
|
|
57
57
|
"publishConfig": {
|
|
@@ -98,11 +98,12 @@ describe("composeFeatures", () => {
|
|
|
98
98
|
});
|
|
99
99
|
|
|
100
100
|
test("app feature duplicating a bundled name is dropped (no createRegistry crash)", () => {
|
|
101
|
-
// create-kumiko-app's picker
|
|
102
|
-
// because the user ticked
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
101
|
+
// Bug discovered during Phase 3 recording: create-kumiko-app's picker
|
|
102
|
+
// hands back createAuthEmailPasswordFeature() because the user ticked
|
|
103
|
+
// it in the recommended set; runDevApp then adds its OWN bundled
|
|
104
|
+
// copy via includeBundled:true, and createRegistry throws "Duplicate
|
|
105
|
+
// feature: auth-email-password". The dedupe path keeps the bundled
|
|
106
|
+
// instance (it carries authOptions wiring) and drops the app stub.
|
|
106
107
|
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
107
108
|
const features = composeFeatures([pickerAuthDupe, noopFeature], {
|
|
108
109
|
includeBundled: true,
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// `relation "kumiko_events" does not exist`. Dieser Test fährt den echten
|
|
7
7
|
// Pfad gegen eine leere DB + den idempotenten Re-Run.
|
|
8
8
|
|
|
9
|
-
import { afterAll, beforeAll, describe, expect,
|
|
9
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
10
10
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
11
11
|
import { tmpdir } from "node:os";
|
|
12
12
|
import { join } from "node:path";
|
|
@@ -76,7 +76,7 @@ describe("runSchemaApply", () => {
|
|
|
76
76
|
expect(await tableExists(conn.db, "public.read_thing")).toBe(true);
|
|
77
77
|
});
|
|
78
78
|
|
|
79
|
-
test("rebuild-Marker für nicht-registrierte Tabelle → kein Crash, 0
|
|
79
|
+
test("rebuild-Marker für nicht-registrierte Tabelle → kein Crash, 0", async () => {
|
|
80
80
|
writeFileSync(
|
|
81
81
|
join(migDir, "0002_more.sql"),
|
|
82
82
|
`CREATE TABLE "read_more" ("id" text PRIMARY KEY);`,
|
|
@@ -86,10 +86,7 @@ describe("runSchemaApply", () => {
|
|
|
86
86
|
JSON.stringify({ version: 1, tables: ["read_more"] }),
|
|
87
87
|
);
|
|
88
88
|
|
|
89
|
-
const warn = spyOn(console, "warn").mockImplementation(() => {});
|
|
90
89
|
expect(await runSchemaApply({ ...APPLY, appCwd })).toBe(0);
|
|
91
90
|
expect(await tableExists(conn.db, "public.read_more")).toBe(true);
|
|
92
|
-
expect(warn).toHaveBeenCalledWith(expect.stringContaining('Table "read_more"'));
|
|
93
|
-
warn.mockRestore();
|
|
94
91
|
});
|
|
95
92
|
});
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { seedAllConfigValues } from "@cosmicdrift/kumiko-bundled-features/config";
|
|
2
|
-
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import type { DbConnection, EncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
-
import type { EnvelopeCipher } from "@cosmicdrift/kumiko-framework/secrets";
|
|
5
4
|
|
|
6
5
|
// Single boot-seed entry-point. runDevApp + runProdApp both call this
|
|
7
6
|
// from their post-stack hook, so the wiring lives in exactly one place
|
|
@@ -13,7 +12,7 @@ import type { EnvelopeCipher } from "@cosmicdrift/kumiko-framework/secrets";
|
|
|
13
12
|
export async function applyBootSeeds(deps: {
|
|
14
13
|
registry: Registry;
|
|
15
14
|
db: DbConnection;
|
|
16
|
-
|
|
15
|
+
encryption?: EncryptionProvider;
|
|
17
16
|
}): Promise<void> {
|
|
18
|
-
await seedAllConfigValues(deps.registry, deps.db, deps.
|
|
17
|
+
await seedAllConfigValues(deps.registry, deps.db, deps.encryption);
|
|
19
18
|
}
|
package/src/run-dev-app.ts
CHANGED
|
@@ -23,21 +23,11 @@ import {
|
|
|
23
23
|
createConfigResolver,
|
|
24
24
|
} from "@cosmicdrift/kumiko-bundled-features/config";
|
|
25
25
|
import {
|
|
26
|
-
createPatResolver,
|
|
27
|
-
PAT_FEATURE,
|
|
28
|
-
patRateLimitFromFeature,
|
|
29
|
-
patScopesFromFeature,
|
|
30
|
-
} from "@cosmicdrift/kumiko-bundled-features/personal-access-tokens";
|
|
31
|
-
import {
|
|
32
|
-
bindAutoRevokeFromFeature,
|
|
33
26
|
createSessionCallbacks,
|
|
34
|
-
SESSIONS_FEATURE,
|
|
35
27
|
type SessionCallbacks,
|
|
36
28
|
} from "@cosmicdrift/kumiko-bundled-features/sessions";
|
|
37
29
|
import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
|
|
38
|
-
import type {
|
|
39
|
-
import { createInMemoryLoginRateLimiter } from "@cosmicdrift/kumiko-framework/api";
|
|
40
|
-
import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
|
|
30
|
+
import type { SessionMetadata } from "@cosmicdrift/kumiko-framework/api";
|
|
41
31
|
import {
|
|
42
32
|
collectWriteHandlerQns,
|
|
43
33
|
createRegistry,
|
|
@@ -51,11 +41,10 @@ import {
|
|
|
51
41
|
validateAppCustomScreenWriteQns,
|
|
52
42
|
validateBoot,
|
|
53
43
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
54
|
-
import type {
|
|
44
|
+
import type { MasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
|
|
55
45
|
import type { TestStack } from "@cosmicdrift/kumiko-framework/stack";
|
|
56
46
|
import { warnIfNonUtcServerTimeZone } from "@cosmicdrift/kumiko-framework/time";
|
|
57
47
|
import { applyBootSeeds } from "./boot/apply-boot-seeds";
|
|
58
|
-
import { resolveBootCrypto } from "./boot/boot-crypto";
|
|
59
48
|
|
|
60
49
|
import { watchAndRegenerate } from "./codegen";
|
|
61
50
|
import { buildComposeAuthOptions, composeFeatures } from "./compose-features";
|
|
@@ -237,14 +226,10 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
|
|
|
237
226
|
...(composeAuthOptions && { authOptions: composeAuthOptions }),
|
|
238
227
|
});
|
|
239
228
|
|
|
240
|
-
//
|
|
241
|
-
// FILE_STORAGE_PROVIDER
|
|
242
|
-
//
|
|
243
|
-
|
|
244
|
-
// process (no files this time) doesn't fall through the gate falsely.
|
|
245
|
-
const setFileStorageProviderEnv =
|
|
246
|
-
options.files !== undefined && process.env["FILE_STORAGE_PROVIDER"] === undefined;
|
|
247
|
-
if (setFileStorageProviderEnv) {
|
|
229
|
+
// Ein explizit gewireter File-Provider (options.files) erfüllt das
|
|
230
|
+
// FILE_STORAGE_PROVIDER-Boot-Gate — der Provider IST konfiguriert, nur
|
|
231
|
+
// nicht über die env-Bridge. Setzen bevor validateBoot greift.
|
|
232
|
+
if (options.files !== undefined && process.env["FILE_STORAGE_PROVIDER"] === undefined) {
|
|
248
233
|
process.env["FILE_STORAGE_PROVIDER"] = "configured";
|
|
249
234
|
}
|
|
250
235
|
|
|
@@ -253,11 +238,7 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
|
|
|
253
238
|
// die früher nur runProdApp fing und sonst erst den Prod-Pod im
|
|
254
239
|
// CrashLoopBackOff sterben ließ (#359). Wirft synchron, bevor ein
|
|
255
240
|
// Socket oder Watcher (codegen-Write) aufgeht.
|
|
256
|
-
|
|
257
|
-
validateBoot(features);
|
|
258
|
-
} finally {
|
|
259
|
-
if (setFileStorageProviderEnv) delete process.env["FILE_STORAGE_PROVIDER"];
|
|
260
|
-
}
|
|
241
|
+
validateBoot(features);
|
|
261
242
|
warnIfNonUtcServerTimeZone();
|
|
262
243
|
validateAppCustomScreenWriteQns(process.cwd(), collectWriteHandlerQns(features));
|
|
263
244
|
|
|
@@ -322,17 +303,8 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
|
|
|
322
303
|
// throwaway-Registry hier extrahiert nur die config-Keys, weil der
|
|
323
304
|
// configResolver vor dem Server-Boot konstruiert wird (stack.registry
|
|
324
305
|
// gibt's erst onAfterSetup) — createKumikoServer baut intern seine eigene.
|
|
325
|
-
const bootCrypto = resolveBootCrypto(envSource, options.masterKey);
|
|
326
|
-
// App-wide cipher for `encrypted: true` entity fields (symmetrisch zu
|
|
327
|
-
// runProdApp) — executors resolve it lazily.
|
|
328
|
-
configureEntityFieldEncryption(bootCrypto.entityFieldCipher);
|
|
329
306
|
const cfgExtra = effectiveAuth
|
|
330
|
-
? mergeConfigResolverDefault(
|
|
331
|
-
options.extraContext,
|
|
332
|
-
createRegistry(features),
|
|
333
|
-
envSource,
|
|
334
|
-
bootCrypto.configCipher,
|
|
335
|
-
)
|
|
307
|
+
? mergeConfigResolverDefault(options.extraContext, createRegistry(features), envSource)
|
|
336
308
|
: options.extraContext;
|
|
337
309
|
// Auto-wire textContent (immer) + secrets (feature-gated), symmetrisch zu
|
|
338
310
|
// runProdApp. Anders als prod existiert die db hier erst im Factory-deps
|
|
@@ -347,7 +319,7 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
|
|
|
347
319
|
registry: deps.registry,
|
|
348
320
|
hasAuth: false,
|
|
349
321
|
sseBroker: deps.sseBroker,
|
|
350
|
-
|
|
322
|
+
...(options.masterKey && { masterKey: options.masterKey }),
|
|
351
323
|
});
|
|
352
324
|
const base = typeof cfgExtra === "function" ? cfgExtra(deps) : (cfgExtra ?? {});
|
|
353
325
|
return { ...boot, ...base };
|
|
@@ -367,26 +339,6 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
|
|
|
367
339
|
}
|
|
368
340
|
return sessionCallbacks;
|
|
369
341
|
};
|
|
370
|
-
// PAT opt-in: same late-bound holder pattern — the resolver needs the real
|
|
371
|
-
// db (only concrete after setupTestStack). Wired when the feature is mounted;
|
|
372
|
-
// scopes come from the feature's exports (single source with its handlers).
|
|
373
|
-
let patResolver: PatResolver | undefined;
|
|
374
|
-
const patFeature = features.find((f) => f.name === PAT_FEATURE);
|
|
375
|
-
const patAuthFragment = patFeature
|
|
376
|
-
? {
|
|
377
|
-
patResolver: (rawToken: string) => {
|
|
378
|
-
if (!patResolver) {
|
|
379
|
-
throw new Error("[runDevApp] pat-resolver accessed before onAfterSetup");
|
|
380
|
-
}
|
|
381
|
-
return patResolver(rawToken);
|
|
382
|
-
},
|
|
383
|
-
patRateLimiter: (() => {
|
|
384
|
-
const rl = patRateLimitFromFeature(patFeature);
|
|
385
|
-
return createInMemoryLoginRateLimiter(rl.maxRequests, rl.windowMs);
|
|
386
|
-
})(),
|
|
387
|
-
}
|
|
388
|
-
: {};
|
|
389
|
-
|
|
390
342
|
const sessionAuthFragment =
|
|
391
343
|
effectiveAuth?.sessions !== undefined
|
|
392
344
|
? {
|
|
@@ -440,7 +392,6 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
|
|
|
440
392
|
unsafeSkipOriginCheck: effectiveAuth.unsafeSkipOriginCheck,
|
|
441
393
|
}),
|
|
442
394
|
...sessionAuthFragment,
|
|
443
|
-
...patAuthFragment,
|
|
444
395
|
...(effectiveAuth.passwordReset && {
|
|
445
396
|
passwordReset: {
|
|
446
397
|
requestHandler: AuthHandlers.requestPasswordReset,
|
|
@@ -485,15 +436,6 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
|
|
|
485
436
|
db: stack.db,
|
|
486
437
|
...(expiresInMs !== undefined && { expiresInMs }),
|
|
487
438
|
});
|
|
488
|
-
// Secure-by-default (symmetrisch zu runProdApp): Password-Change/
|
|
489
|
-
// -Reset mass-revoked die Sessions des Users ohne App-Opt-in.
|
|
490
|
-
const sessionsFeature = features.find((f) => f.name === SESSIONS_FEATURE);
|
|
491
|
-
if (sessionsFeature) {
|
|
492
|
-
bindAutoRevokeFromFeature(sessionsFeature)?.(sessionCallbacks.sessionMassRevoker);
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
if (patFeature) {
|
|
496
|
-
patResolver = createPatResolver({ db: stack.db, scopes: patScopesFromFeature(patFeature) });
|
|
497
439
|
}
|
|
498
440
|
if (effectiveAuth) {
|
|
499
441
|
await seedAdmin(stack.db, effectiveAuth.admin);
|
|
@@ -502,11 +444,7 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
|
|
|
502
444
|
// Runs before user-supplied seed callbacks so those can read /
|
|
503
445
|
// override the deploy-defaults. The helper indirection is what
|
|
504
446
|
// config-seed-boot.integration.ts pins — keep it as a single call.
|
|
505
|
-
await applyBootSeeds({
|
|
506
|
-
registry: stack.registry,
|
|
507
|
-
db: stack.db,
|
|
508
|
-
...(bootCrypto.configCipher && { cipher: bootCrypto.configCipher }),
|
|
509
|
-
});
|
|
447
|
+
await applyBootSeeds({ registry: stack.registry, db: stack.db });
|
|
510
448
|
for (const seed of options.seeds ?? []) {
|
|
511
449
|
await seed(stack);
|
|
512
450
|
}
|
|
@@ -542,12 +480,10 @@ export function mergeConfigResolverDefault(
|
|
|
542
480
|
ctx: CreateKumikoServerOptions["extraContext"],
|
|
543
481
|
registry: Registry,
|
|
544
482
|
envSource: Record<string, string | undefined>,
|
|
545
|
-
cipher?: EnvelopeCipher,
|
|
546
483
|
): CreateKumikoServerOptions["extraContext"] {
|
|
547
484
|
const defaults = {
|
|
548
485
|
configResolver: createConfigResolver({
|
|
549
486
|
appOverrides: buildEnvConfigOverrides(registry, envSource),
|
|
550
|
-
...(cipher && { cipher }),
|
|
551
487
|
}),
|
|
552
488
|
};
|
|
553
489
|
// ctx.config wird per-Request aus _configAccessorFactory geminted (siehe
|
package/src/run-prod-app.ts
CHANGED
|
@@ -50,18 +50,11 @@ import {
|
|
|
50
50
|
createDeliveryService,
|
|
51
51
|
DELIVERY_FEATURE,
|
|
52
52
|
} from "@cosmicdrift/kumiko-bundled-features/delivery";
|
|
53
|
-
import {
|
|
54
|
-
createPatResolver,
|
|
55
|
-
PAT_FEATURE,
|
|
56
|
-
patRateLimitFromFeature,
|
|
57
|
-
patScopesFromFeature,
|
|
58
|
-
} from "@cosmicdrift/kumiko-bundled-features/personal-access-tokens";
|
|
59
53
|
import {
|
|
60
54
|
createSecretsContext,
|
|
61
55
|
SECRETS_FEATURE_NAME,
|
|
62
56
|
} from "@cosmicdrift/kumiko-bundled-features/secrets";
|
|
63
57
|
import {
|
|
64
|
-
bindAutoRevokeFromFeature,
|
|
65
58
|
createSessionCallbacks,
|
|
66
59
|
SESSIONS_FEATURE,
|
|
67
60
|
} from "@cosmicdrift/kumiko-bundled-features/sessions";
|
|
@@ -73,12 +66,10 @@ import {
|
|
|
73
66
|
cachedResponse,
|
|
74
67
|
computeStrongEtag,
|
|
75
68
|
computeWeakEtag,
|
|
76
|
-
createInMemoryLoginRateLimiter,
|
|
77
69
|
createSseBroker,
|
|
78
70
|
type SseBroker,
|
|
79
71
|
} from "@cosmicdrift/kumiko-framework/api";
|
|
80
72
|
import {
|
|
81
|
-
configureEntityFieldEncryption,
|
|
82
73
|
createDbConnection,
|
|
83
74
|
type DbConnection,
|
|
84
75
|
type DbRunner,
|
|
@@ -124,11 +115,13 @@ import {
|
|
|
124
115
|
createEventDedup,
|
|
125
116
|
createIdempotencyGuard,
|
|
126
117
|
} from "@cosmicdrift/kumiko-framework/pipeline";
|
|
127
|
-
import
|
|
118
|
+
import {
|
|
119
|
+
createEnvMasterKeyProvider,
|
|
120
|
+
type MasterKeyProvider,
|
|
121
|
+
} from "@cosmicdrift/kumiko-framework/secrets";
|
|
128
122
|
import { warnIfNonUtcServerTimeZone } from "@cosmicdrift/kumiko-framework/time";
|
|
129
123
|
import Redis from "ioredis";
|
|
130
124
|
import { applyBootSeeds } from "./boot/apply-boot-seeds";
|
|
131
|
-
import { type BootCrypto, resolveBootCrypto } from "./boot/boot-crypto";
|
|
132
125
|
import { ASSETS_DIR } from "./build-prod-bundle";
|
|
133
126
|
import { buildComposeAuthOptions, composeFeatures } from "./compose-features";
|
|
134
127
|
import { type ExtraRoutesSystemDeps, makeDispatchSystemWrite } from "./extra-routes-deps";
|
|
@@ -577,7 +570,14 @@ export type ProdAppHandle = {
|
|
|
577
570
|
readonly stop: () => Promise<void>;
|
|
578
571
|
};
|
|
579
572
|
|
|
580
|
-
//
|
|
573
|
+
// Mint `ctx.config` per request: the dispatcher only builds a per-user
|
|
574
|
+
// ConfigAccessor when `_configAccessorFactory` is on the AppContext
|
|
575
|
+
// (pipeline/dispatcher.ts). Without it `ctx.config` stays undefined and any
|
|
576
|
+
// handler reading it — e.g. createFileProviderForTenant for the GDPR export
|
|
577
|
+
// download — throws "ctx.config is missing". Built from the EFFECTIVE resolver
|
|
578
|
+
// so an app-supplied configResolver override (its appOverrides) is the one
|
|
579
|
+
// ctx.config reads. Shared with runDevApp (mergeConfigResolverDefault) for
|
|
580
|
+
// dev/prod parity.
|
|
581
581
|
export function addConfigAccessorFactory<T extends { readonly configResolver?: ConfigResolver }>(
|
|
582
582
|
resolved: T,
|
|
583
583
|
registry: Registry,
|
|
@@ -596,13 +596,18 @@ export function addConfigAccessorFactory<T extends { readonly configResolver?: C
|
|
|
596
596
|
// nur einen db-gebundenen Accessor). secrets wird nur auto-verdrahtet wenn
|
|
597
597
|
// das secrets-Feature gemountet ist UND ein KEK tatsächlich verfügbar ist
|
|
598
598
|
// (masterKey-Override ODER env-KEK present) — sonst skip, damit der eager
|
|
599
|
-
//
|
|
600
|
-
// (
|
|
599
|
+
// createEnvMasterKeyProvider nicht wirft. Das deckt zwei Fälle: (a) App ohne
|
|
600
|
+
// secrets → kein KEK-Zwang; (b) dev mit App-eigenem DEV-KEK in extraContext
|
|
601
|
+
// (kein env-KEK) → kein Boot-Crash, die App-explizite secrets-Wiring gewinnt.
|
|
601
602
|
// Prod-Misconfig (secrets gemountet, kein KEK) fängt schon secretsEnvSchema
|
|
602
603
|
// beim Boot; fehlt der env-Schema-Pfad, wirft requireSecretsContext beim
|
|
603
604
|
// ersten ctx.secrets-Zugriff mit Wiring-Hinweis. configResolver nur im
|
|
604
605
|
// Auth-Mode. Exportiert + pure für Unit-Tests; der merge mit App-Werten
|
|
605
606
|
// passiert beim Caller (App gewinnt).
|
|
607
|
+
const MASTER_KEK_VAR = /^KUMIKO_SECRETS_MASTER_KEY_V\d+$/;
|
|
608
|
+
function envHasMasterKek(env: Record<string, string | undefined>): boolean {
|
|
609
|
+
return Object.entries(env).some(([k, v]) => MASTER_KEK_VAR.test(k) && !!v);
|
|
610
|
+
}
|
|
606
611
|
|
|
607
612
|
// Prod/dev parity for ctx.notify: without this `_notifyFactory` is only wired
|
|
608
613
|
// in tests (createDeliveryTestContext), so ctx.notify is undefined at runtime
|
|
@@ -629,16 +634,12 @@ export function buildBootExtraContext(opts: {
|
|
|
629
634
|
readonly envSource: Record<string, string | undefined>;
|
|
630
635
|
readonly registry: Registry;
|
|
631
636
|
readonly hasAuth: boolean;
|
|
632
|
-
// Resolved once per boot via resolveBootCrypto — shared by secrets,
|
|
633
|
-
// config-resolver, config-set-handler and boot-seeds. Absent (tests
|
|
634
|
-
// that don't care about encryption) ⇒ resolved from envSource here.
|
|
635
|
-
readonly crypto?: BootCrypto;
|
|
636
637
|
readonly masterKey?: MasterKeyProvider;
|
|
637
638
|
readonly sseBroker?: SseBroker;
|
|
638
639
|
}): Record<string, unknown> {
|
|
639
|
-
const crypto = opts.crypto ?? resolveBootCrypto(opts.envSource, opts.masterKey);
|
|
640
640
|
const hasSecretsFeature = opts.features.some((f) => f.name === SECRETS_FEATURE_NAME);
|
|
641
|
-
const wireSecrets =
|
|
641
|
+
const wireSecrets =
|
|
642
|
+
hasSecretsFeature && (opts.masterKey !== undefined || envHasMasterKek(opts.envSource));
|
|
642
643
|
const hasDeliveryFeature = opts.features.some((f) => f.name === DELIVERY_FEATURE);
|
|
643
644
|
return {
|
|
644
645
|
textContent: createTextContentApi(opts.db),
|
|
@@ -649,25 +650,25 @@ export function buildBootExtraContext(opts: {
|
|
|
649
650
|
...(opts.sseBroker && { sseBroker: opts.sseBroker }),
|
|
650
651
|
}),
|
|
651
652
|
}),
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
653
|
+
...(wireSecrets && {
|
|
654
|
+
secrets: createSecretsContext({
|
|
655
|
+
db: opts.db,
|
|
656
|
+
masterKeyProvider:
|
|
657
|
+
opts.masterKey ??
|
|
658
|
+
createEnvMasterKeyProvider({
|
|
659
|
+
// CURRENT_VERSION default "1" spiegelt secretsEnvSchema — ohne
|
|
660
|
+
// ihn wirft der raw-env-Provider, obwohl V1 gesetzt ist.
|
|
661
|
+
env: {
|
|
662
|
+
...opts.envSource,
|
|
663
|
+
KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION:
|
|
664
|
+
opts.envSource["KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION"] ?? "1",
|
|
665
|
+
},
|
|
666
|
+
}),
|
|
666
667
|
}),
|
|
668
|
+
}),
|
|
667
669
|
...(opts.hasAuth && {
|
|
668
670
|
configResolver: createConfigResolver({
|
|
669
671
|
appOverrides: buildEnvConfigOverrides(opts.registry, opts.envSource),
|
|
670
|
-
...(crypto.configCipher && { cipher: crypto.configCipher }),
|
|
671
672
|
}),
|
|
672
673
|
}),
|
|
673
674
|
};
|
|
@@ -918,10 +919,6 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
918
919
|
|
|
919
920
|
// Framework-Default-Provider zuerst, App-Werte (resolvedExtraContext)
|
|
920
921
|
// gewinnen immer (z.B. money-horse's eigener configResolver).
|
|
921
|
-
const bootCrypto = resolveBootCrypto(envSource, options.masterKey);
|
|
922
|
-
// App-wide cipher for `encrypted: true` entity fields — executors resolve
|
|
923
|
-
// it lazily, entities without encrypted fields never touch it.
|
|
924
|
-
configureEntityFieldEncryption(bootCrypto.entityFieldCipher);
|
|
925
922
|
const autoExtraContext = buildBootExtraContext({
|
|
926
923
|
db,
|
|
927
924
|
features,
|
|
@@ -929,7 +926,7 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
929
926
|
registry,
|
|
930
927
|
hasAuth: !!effectiveAuth,
|
|
931
928
|
sseBroker,
|
|
932
|
-
|
|
929
|
+
...(options.masterKey && { masterKey: options.masterKey }),
|
|
933
930
|
});
|
|
934
931
|
const extraContext = addConfigAccessorFactory(
|
|
935
932
|
{ ...autoExtraContext, ...resolvedExtraContext },
|
|
@@ -945,40 +942,21 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
945
942
|
// sessionStrictMode=true: Prod-Sessions sollen nicht stillschweigend
|
|
946
943
|
// von einem JWT-ohne-sid umgangen werden können. sessionMassRevoker
|
|
947
944
|
// (4. callback aus createSessionCallbacks) ist nicht Teil der
|
|
948
|
-
// AuthRoutesConfig-Surface — der
|
|
949
|
-
//
|
|
950
|
-
//
|
|
945
|
+
// AuthRoutesConfig-Surface — der wird vom sessions-Feature selbst über
|
|
946
|
+
// die `autoRevokeOnPasswordChange`-Option konsumiert, nicht über die
|
|
947
|
+
// auth-routes.
|
|
951
948
|
// Secure-by-default: if the sessions feature is mounted, server-side revocation +
|
|
952
|
-
// sessionStrictMode
|
|
953
|
-
// `auth.sessions`
|
|
954
|
-
|
|
955
|
-
const sessionsFeature = features.find((f) => f.name === SESSIONS_FEATURE);
|
|
949
|
+
// sessionStrictMode are wired automatically; `auth.sessions` only overrides the config,
|
|
950
|
+
// and `auth.sessions: false` is the explicit opt-out (back to stateless JWTs).
|
|
951
|
+
const sessionsFeatureMounted = features.some((f) => f.name === SESSIONS_FEATURE);
|
|
956
952
|
const sessionAuthFragment = shouldWireProdSessions(
|
|
957
953
|
Boolean(effectiveAuth),
|
|
958
|
-
|
|
954
|
+
sessionsFeatureMounted,
|
|
959
955
|
effectiveAuth?.sessions,
|
|
960
956
|
)
|
|
961
|
-
? buildProdSessionAuth(db, resolveProdSessionsConfig(effectiveAuth?.sessions)
|
|
957
|
+
? buildProdSessionAuth(db, resolveProdSessionsConfig(effectiveAuth?.sessions))
|
|
962
958
|
: undefined;
|
|
963
959
|
|
|
964
|
-
// PAT opt-in: if the personal-access-tokens feature is mounted, wire its
|
|
965
|
-
// resolver (bearer PATs → SessionUser, before jwt.verify). Scopes come from
|
|
966
|
-
// the feature's exports — the same declaration its handlers use.
|
|
967
|
-
const patFeature = features.find((f) => f.name === PAT_FEATURE);
|
|
968
|
-
let patAuthFragment:
|
|
969
|
-
| {
|
|
970
|
-
patResolver: ReturnType<typeof createPatResolver>;
|
|
971
|
-
patRateLimiter: ReturnType<typeof createInMemoryLoginRateLimiter>;
|
|
972
|
-
}
|
|
973
|
-
| undefined;
|
|
974
|
-
if (effectiveAuth && patFeature) {
|
|
975
|
-
const rl = patRateLimitFromFeature(patFeature);
|
|
976
|
-
patAuthFragment = {
|
|
977
|
-
patResolver: createPatResolver({ db, scopes: patScopesFromFeature(patFeature) }),
|
|
978
|
-
patRateLimiter: createInMemoryLoginRateLimiter(rl.maxRequests, rl.windowMs),
|
|
979
|
-
};
|
|
980
|
-
}
|
|
981
|
-
|
|
982
960
|
const baseEntrypointOptions = {
|
|
983
961
|
registry,
|
|
984
962
|
context: {
|
|
@@ -1016,7 +994,6 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
1016
994
|
unsafeSkipOriginCheck: effectiveAuth.unsafeSkipOriginCheck,
|
|
1017
995
|
}),
|
|
1018
996
|
...sessionAuthFragment,
|
|
1019
|
-
...patAuthFragment,
|
|
1020
997
|
...(effectiveAuth.passwordReset && {
|
|
1021
998
|
passwordReset: {
|
|
1022
999
|
requestHandler: AuthHandlers.requestPasswordReset,
|
|
@@ -1108,11 +1085,7 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
1108
1085
|
if (effectiveAuth) {
|
|
1109
1086
|
await seedAdmin(db, effectiveAuth.admin);
|
|
1110
1087
|
}
|
|
1111
|
-
await applyBootSeeds({
|
|
1112
|
-
registry,
|
|
1113
|
-
db,
|
|
1114
|
-
...(bootCrypto.configCipher && { cipher: bootCrypto.configCipher }),
|
|
1115
|
-
});
|
|
1088
|
+
await applyBootSeeds({ registry, db });
|
|
1116
1089
|
for (const seed of options.seeds ?? []) {
|
|
1117
1090
|
await seed({ db });
|
|
1118
1091
|
}
|
|
@@ -1500,7 +1473,6 @@ export function staticCachePolicy(pathname: string): CachePolicy {
|
|
|
1500
1473
|
function buildProdSessionAuth(
|
|
1501
1474
|
db: import("@cosmicdrift/kumiko-framework/db").DbConnection,
|
|
1502
1475
|
opts: ProdSessionsConfig,
|
|
1503
|
-
sessionsFeature: import("@cosmicdrift/kumiko-framework/engine").FeatureDefinition | undefined,
|
|
1504
1476
|
): {
|
|
1505
1477
|
readonly sessionCreator: ReturnType<typeof createSessionCallbacks>["sessionCreator"];
|
|
1506
1478
|
readonly sessionRevoker: ReturnType<typeof createSessionCallbacks>["sessionRevoker"];
|
|
@@ -1511,11 +1483,6 @@ function buildProdSessionAuth(
|
|
|
1511
1483
|
db,
|
|
1512
1484
|
...(opts.expiresInMs !== undefined && { expiresInMs: opts.expiresInMs }),
|
|
1513
1485
|
});
|
|
1514
|
-
// Secure-by-default: password-change/-reset mass-revokes the user's live
|
|
1515
|
-
// sessions without the app opting in via autoRevokeOnPasswordChange.
|
|
1516
|
-
if (sessionsFeature) {
|
|
1517
|
-
bindAutoRevokeFromFeature(sessionsFeature)?.(cbs.sessionMassRevoker);
|
|
1518
|
-
}
|
|
1519
1486
|
return {
|
|
1520
1487
|
sessionCreator: cbs.sessionCreator,
|
|
1521
1488
|
sessionRevoker: cbs.sessionRevoker,
|
package/src/scaffold-app.ts
CHANGED
|
@@ -611,20 +611,19 @@ KUMIKO_DEV_DB_NAME=${devDb}
|
|
|
611
611
|
`;
|
|
612
612
|
}
|
|
613
613
|
|
|
614
|
-
// Ports + credentials match the *_URL
|
|
615
|
-
// `docker compose up -d`
|
|
616
|
-
//
|
|
617
|
-
//
|
|
618
|
-
//
|
|
614
|
+
// Local Postgres + Redis for `bun dev`. Ports + credentials match the *_URL
|
|
615
|
+
// defaults in renderEnvExample, so `docker compose up -d` (referenced by the
|
|
616
|
+
// README) just works with the generated .env. Named pg volume so dev data
|
|
617
|
+
// survives `docker compose down` (pairs with KUMIKO_DEV_DB_NAME persistence).
|
|
618
|
+
// Ports bind to 127.0.0.1 only: the dev DB (postgres/postgres) and auth-less
|
|
619
|
+
// Redis must not be reachable from the LAN on a machine without a firewall.
|
|
619
620
|
function renderDockerCompose(): string {
|
|
620
621
|
return `# Local Postgres + Redis for \`bun dev\`. Matches the *_URL defaults in .env.example.
|
|
621
622
|
# Start: docker compose up -d · Stop: docker compose down · Reset: docker compose down -v
|
|
622
623
|
# Ports bind to 127.0.0.1 only — weak dev credentials must not be exposed on the LAN.
|
|
623
624
|
services:
|
|
624
625
|
postgres:
|
|
625
|
-
|
|
626
|
-
# (~90MB vs ~400MB) and a reproducible patch version, bump on PG18 minors.
|
|
627
|
-
image: postgres:18.3-alpine
|
|
626
|
+
image: postgres:18
|
|
628
627
|
environment:
|
|
629
628
|
POSTGRES_USER: postgres
|
|
630
629
|
POSTGRES_PASSWORD: postgres
|
package/src/schema-apply.ts
CHANGED
|
@@ -100,16 +100,7 @@ async function rebuildAffectedProjections(
|
|
|
100
100
|
const projections = new Set<string>();
|
|
101
101
|
for (const table of changedTables) {
|
|
102
102
|
const name = tableToProjection.get(table);
|
|
103
|
-
if (name)
|
|
104
|
-
projections.add(name);
|
|
105
|
-
} else {
|
|
106
|
-
// 522/3: a table in a .rebuild.json marker that no longer matches any
|
|
107
|
-
// registered projection would otherwise rebuild nothing and exit 0 —
|
|
108
|
-
// indistinguishable from "nothing needed a rebuild".
|
|
109
|
-
console.warn(
|
|
110
|
-
` ⚠ Table "${table}" is in a rebuild marker but matches no registered projection — skipped.`,
|
|
111
|
-
);
|
|
112
|
-
}
|
|
103
|
+
if (name) projections.add(name);
|
|
113
104
|
}
|
|
114
105
|
if (projections.size === 0) return;
|
|
115
106
|
|
package/src/welcome-banner.ts
CHANGED
|
@@ -37,7 +37,10 @@ export function renderWelcomeBanner(input: WelcomeBannerInput): string {
|
|
|
37
37
|
return [top, ...padded, bottom].join("\n");
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
//
|
|
40
|
+
// Plain monospace-cell width — counts each codepoint as one cell. Good
|
|
41
|
+
// enough for ASCII + the small set of arrows/checkmarks used above; if a
|
|
42
|
+
// real wide-char ever lands in the banner the row alignment will drift,
|
|
43
|
+
// caught visually by the snapshot test.
|
|
41
44
|
function stringWidth(s: string): number {
|
|
42
45
|
return [...s].length;
|
|
43
46
|
}
|
package/src/boot/boot-crypto.ts
DELETED
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
// One place that turns env/options into the app-wide crypto objects:
|
|
2
|
-
// the MasterKeyProvider (KUMIKO_SECRETS_MASTER_KEY_V<n> keyring) and the
|
|
3
|
-
// EnvelopeCipher for `encrypted: true` config keys. runProdApp and
|
|
4
|
-
// runDevApp both resolve this once and thread it into
|
|
5
|
-
// buildBootExtraContext + applyBootSeeds so resolver, set-handler and
|
|
6
|
-
// seeds share the same cipher instance (and DEK cache).
|
|
7
|
-
|
|
8
|
-
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
9
|
-
import {
|
|
10
|
-
createDekCache,
|
|
11
|
-
createEnvelopeCipher,
|
|
12
|
-
createEnvMasterKeyProvider,
|
|
13
|
-
type DekCache,
|
|
14
|
-
type EnvelopeCipher,
|
|
15
|
-
type MasterKeyProvider,
|
|
16
|
-
} from "@cosmicdrift/kumiko-framework/secrets";
|
|
17
|
-
|
|
18
|
-
// Keyring detection without throwing: only build the env provider when at
|
|
19
|
-
// least one versioned KEK is actually set. Covers (a) apps without any
|
|
20
|
-
// encryption — no KEK requirement, and (b) dev with an app-supplied
|
|
21
|
-
// provider in options.masterKey — env stays irrelevant.
|
|
22
|
-
const MASTER_KEK_VAR = /^KUMIKO_SECRETS_MASTER_KEY_V\d+$/;
|
|
23
|
-
export function envHasMasterKek(env: Record<string, string | undefined>): boolean {
|
|
24
|
-
return Object.entries(env).some(([k, v]) => MASTER_KEK_VAR.test(k) && !!v);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export type BootCrypto = {
|
|
28
|
-
readonly masterKeyProvider?: MasterKeyProvider;
|
|
29
|
-
// Cipher for encrypted config keys. Present exactly when a master key is
|
|
30
|
-
// available. Decrypts legacy CONFIG_ENCRYPTION_KEY values as fallback
|
|
31
|
-
// until the config re-encrypt job migrated them.
|
|
32
|
-
readonly configCipher?: EnvelopeCipher;
|
|
33
|
-
// Cipher for `encrypted: true` entity fields — same master key, but the
|
|
34
|
-
// legacy fallback reads the pre-envelope ENCRYPTION_KEY format. Identical
|
|
35
|
-
// to configCipher when no ENCRYPTION_KEY is set.
|
|
36
|
-
readonly entityFieldCipher?: EnvelopeCipher;
|
|
37
|
-
readonly dekCache: DekCache;
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
export function resolveBootCrypto(
|
|
41
|
-
envSource: Record<string, string | undefined>,
|
|
42
|
-
masterKeyOverride?: MasterKeyProvider,
|
|
43
|
-
): BootCrypto {
|
|
44
|
-
const masterKeyProvider =
|
|
45
|
-
masterKeyOverride ??
|
|
46
|
-
(envHasMasterKek(envSource)
|
|
47
|
-
? createEnvMasterKeyProvider({
|
|
48
|
-
// CURRENT_VERSION default "1" spiegelt secretsEnvSchema — ohne
|
|
49
|
-
// ihn wirft der raw-env-Provider, obwohl V1 gesetzt ist.
|
|
50
|
-
env: {
|
|
51
|
-
...envSource,
|
|
52
|
-
KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION:
|
|
53
|
-
envSource["KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION"] ?? "1",
|
|
54
|
-
},
|
|
55
|
-
})
|
|
56
|
-
: undefined);
|
|
57
|
-
|
|
58
|
-
const dekCache = createDekCache();
|
|
59
|
-
const legacyConfigKey = envSource["CONFIG_ENCRYPTION_KEY"];
|
|
60
|
-
const configCipher = masterKeyProvider
|
|
61
|
-
? createEnvelopeCipher(masterKeyProvider, {
|
|
62
|
-
dekCache,
|
|
63
|
-
...(legacyConfigKey ? { legacy: createEncryptionProvider(legacyConfigKey) } : {}),
|
|
64
|
-
})
|
|
65
|
-
: undefined;
|
|
66
|
-
|
|
67
|
-
const legacyEntityKey = envSource["ENCRYPTION_KEY"];
|
|
68
|
-
const entityFieldCipher =
|
|
69
|
-
masterKeyProvider && legacyEntityKey
|
|
70
|
-
? createEnvelopeCipher(masterKeyProvider, {
|
|
71
|
-
dekCache,
|
|
72
|
-
legacy: createEncryptionProvider(legacyEntityKey),
|
|
73
|
-
})
|
|
74
|
-
: configCipher;
|
|
75
|
-
|
|
76
|
-
return {
|
|
77
|
-
...(masterKeyProvider && { masterKeyProvider }),
|
|
78
|
-
...(configCipher && { configCipher }),
|
|
79
|
-
...(entityFieldCipher && { entityFieldCipher }),
|
|
80
|
-
dekCache,
|
|
81
|
-
};
|
|
82
|
-
}
|