@cosmicdrift/kumiko-dev-server 0.146.4 → 0.147.1
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-stacks.test.ts +22 -3
- package/src/__tests__/few-shot-corpus.test.ts +15 -10
- package/src/__tests__/merge-extra-context.test.ts +56 -0
- package/src/codegen/__tests__/strict-mode-diagnostics.test.ts +5 -0
- package/src/compose-stacks.ts +4 -3
- package/src/create-kumiko-server.ts +6 -0
- package/src/run-prod-app-boot-context.ts +234 -0
- package/src/run-prod-app-static-files.ts +273 -0
- package/src/run-prod-app.ts +27 -481
- package/src/scaffold-app-feature.ts +5 -4
- package/src/setup-test-stack-from-features.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-dev-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.147.1",
|
|
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>",
|
|
@@ -58,8 +58,8 @@
|
|
|
58
58
|
"kumiko-schema-check": "./bin/kumiko-schema-check.ts"
|
|
59
59
|
},
|
|
60
60
|
"dependencies": {
|
|
61
|
-
"@cosmicdrift/kumiko-bundled-features": "0.
|
|
62
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
61
|
+
"@cosmicdrift/kumiko-bundled-features": "0.147.1",
|
|
62
|
+
"@cosmicdrift/kumiko-framework": "0.147.1",
|
|
63
63
|
"ts-morph": "^28.0.0"
|
|
64
64
|
},
|
|
65
65
|
"publishConfig": {
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { validateBoot } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { composeFeatures } from "../compose-features";
|
|
2
4
|
import {
|
|
3
5
|
composeFileStack,
|
|
4
6
|
composeGdprStack,
|
|
@@ -77,8 +79,25 @@ describe("composeStacks", () => {
|
|
|
77
79
|
});
|
|
78
80
|
});
|
|
79
81
|
|
|
80
|
-
/**
|
|
81
|
-
describe("composeStacks
|
|
82
|
+
/** Snapshot, not a live parity check: intended block combinations, mirrored by hand from studio/money-horse/publicstatus run-configs. A drifted run-config stays green here. */
|
|
83
|
+
describe("composeStacks boots for real", () => {
|
|
84
|
+
test("studio-shaped combined stack passes validateBoot (not just name-list comparison)", () => {
|
|
85
|
+
const features = composeFeatures(
|
|
86
|
+
[
|
|
87
|
+
...composeOpsStack({ rateLimiting: true }),
|
|
88
|
+
...composePagesStack(),
|
|
89
|
+
...composeMailStack({ transports: ["inmemory"] }),
|
|
90
|
+
...composeFileStack({ providers: ["inmemory"] }),
|
|
91
|
+
...composeGdprStack({ order: "compliance-first", sessions: true }),
|
|
92
|
+
...composeUserDataRightsStack(),
|
|
93
|
+
],
|
|
94
|
+
{ includeBundled: true },
|
|
95
|
+
);
|
|
96
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("composeStacks intended block names (snapshot, not live parity)", () => {
|
|
82
101
|
test("studio SaaS blocks are covered by presets", () => {
|
|
83
102
|
const names = stackFeatureNames([
|
|
84
103
|
...composeOpsStack({ rateLimiting: true }),
|
|
@@ -135,7 +154,7 @@ describe("composeStacks parity (feature names)", () => {
|
|
|
135
154
|
const names = stackFeatureNames([
|
|
136
155
|
...composePagesStack(),
|
|
137
156
|
...composeGdprStack({ tenantLifecycle: true }),
|
|
138
|
-
...composeOpsStack({ delivery: true, audit: true, jobs: true
|
|
157
|
+
...composeOpsStack({ delivery: true, audit: true, jobs: true }),
|
|
139
158
|
...composeRendererStack(),
|
|
140
159
|
...composeFileStack({ providers: ["inmemory"] }),
|
|
141
160
|
...composeUserDataRightsStack(),
|
|
@@ -44,7 +44,7 @@ describe("buildFewShotCorpus — smoke", () => {
|
|
|
44
44
|
});
|
|
45
45
|
plantPackage(workdir, "samples/recipes/legacy-demo", {
|
|
46
46
|
pkgName: "@cosmicdrift/kumiko-sample-legacy-demo",
|
|
47
|
-
pkgDescription: "
|
|
47
|
+
pkgDescription: "Handler-object-reference-style legacy feature.",
|
|
48
48
|
featureSource: buildLegacyFeature("legacyDemo"),
|
|
49
49
|
});
|
|
50
50
|
|
|
@@ -293,19 +293,24 @@ defineFeature("${featureName}", (r) => {
|
|
|
293
293
|
}
|
|
294
294
|
|
|
295
295
|
function buildLegacyFeature(featureName: string): string {
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
296
|
+
// Handler-object-reference style — writeHandler receives a single
|
|
297
|
+
// identifier referencing a pre-built handler object rather than an
|
|
298
|
+
// inline string name + schema + handler. The parser has no extractor
|
|
299
|
+
// shape for that (it only reads a literal name as the first arg, or
|
|
300
|
+
// an inline object-literal form), so it produces a ParseError and the
|
|
301
|
+
// corpus marks the entry as authoringStyle: "legacy".
|
|
302
|
+
//
|
|
303
|
+
// Note: a captured-const *entity definition* (`r.entity("x", xEntity)`)
|
|
304
|
+
// used to be the legacy example here, but #998 taught the parser to
|
|
305
|
+
// resolve such references via a raw-ref sentinel — that idiom parses
|
|
306
|
+
// cleanly now and no longer exercises the legacy path.
|
|
299
307
|
return `
|
|
300
|
-
import { defineFeature
|
|
308
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
301
309
|
|
|
302
|
-
const
|
|
303
|
-
table: "items",
|
|
304
|
-
fields: { title: createTextField({ required: true }) },
|
|
305
|
-
});
|
|
310
|
+
const itemCreateHandler = { name: "item:create" };
|
|
306
311
|
|
|
307
312
|
defineFeature("${featureName}", (r) => {
|
|
308
|
-
r.
|
|
313
|
+
r.writeHandler(itemCreateHandler);
|
|
309
314
|
});
|
|
310
315
|
`;
|
|
311
316
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mergeExtraContext } from "../setup-test-stack-from-features";
|
|
3
|
+
|
|
4
|
+
// #882/4: the integration test only asserted getFeature("config") — which
|
|
5
|
+
// includeBundled already provides — never that presets:["config"] actually
|
|
6
|
+
// merges configResolver/textContent into extraContext. Unit-test the merge
|
|
7
|
+
// directly instead.
|
|
8
|
+
|
|
9
|
+
const fakeDeps = {
|
|
10
|
+
registry: {} as never,
|
|
11
|
+
db: { marker: "fake-db" } as never,
|
|
12
|
+
sseBroker: {} as never,
|
|
13
|
+
redis: {} as never,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
describe("mergeExtraContext", () => {
|
|
17
|
+
test("no presets → returns base untouched (function passthrough)", () => {
|
|
18
|
+
const base = () => ({ foo: "bar" });
|
|
19
|
+
const merged = mergeExtraContext(base, []);
|
|
20
|
+
expect(merged).toBe(base);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("no presets → returns base untouched (object passthrough)", () => {
|
|
24
|
+
const base = { foo: "bar" };
|
|
25
|
+
const merged = mergeExtraContext(base, []);
|
|
26
|
+
expect(merged).toBe(base);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("config preset merges configResolver, keeping base-object fields", () => {
|
|
30
|
+
const merged = mergeExtraContext({ foo: "bar" }, ["config"]);
|
|
31
|
+
expect(typeof merged).toBe("function");
|
|
32
|
+
const result = (merged as (deps: typeof fakeDeps) => Record<string, unknown>)(fakeDeps);
|
|
33
|
+
expect(result["foo"]).toBe("bar");
|
|
34
|
+
expect(result["configResolver"]).toBeDefined();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("config preset merges configResolver, keeping base-fn fields", () => {
|
|
38
|
+
const merged = mergeExtraContext(() => ({ fromFn: 1 }), ["config"]);
|
|
39
|
+
const result = (merged as (deps: typeof fakeDeps) => Record<string, unknown>)(fakeDeps);
|
|
40
|
+
expect(result["fromFn"]).toBe(1);
|
|
41
|
+
expect(result["configResolver"]).toBeDefined();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("text-content preset merges textContent, built from deps.db", () => {
|
|
45
|
+
const merged = mergeExtraContext(undefined, ["text-content"]);
|
|
46
|
+
const result = (merged as (deps: typeof fakeDeps) => Record<string, unknown>)(fakeDeps);
|
|
47
|
+
expect(result["textContent"]).toBeDefined();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("both presets merge both fields", () => {
|
|
51
|
+
const merged = mergeExtraContext(undefined, ["config", "text-content"]);
|
|
52
|
+
const result = (merged as (deps: typeof fakeDeps) => Record<string, unknown>)(fakeDeps);
|
|
53
|
+
expect(result["configResolver"]).toBeDefined();
|
|
54
|
+
expect(result["textContent"]).toBeDefined();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
2
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
|
+
// TS7's package.json exports only version.cjs at ".", so the classic
|
|
5
|
+
// createProgram/ScriptTarget Compiler API is unreachable via `import *
|
|
6
|
+
// as ts from "typescript"` anymore; ts-morph's vendored (older) compiler
|
|
7
|
+
// build is the only way to get that surface, so diagnostics here run
|
|
8
|
+
// against a different compiler than the repo's own tsc.
|
|
4
9
|
import { ts } from "ts-morph";
|
|
5
10
|
import { runCodegen } from "../run-codegen";
|
|
6
11
|
|
package/src/compose-stacks.ts
CHANGED
|
@@ -61,11 +61,14 @@ export type UserDataRightsStackOptions = {
|
|
|
61
61
|
readonly includeDefaults?: boolean;
|
|
62
62
|
};
|
|
63
63
|
|
|
64
|
+
// `sessions` lives ONLY on GdprStackOptions — composeOpsStack used to accept
|
|
65
|
+
// it too, and combining both presets with sessions:true double-pushed the
|
|
66
|
+
// "sessions" feature name into the registry, which createRegistry rejects
|
|
67
|
+
// with "duplicate feature" (a boot-time crash, not a warning).
|
|
64
68
|
export type OpsStackOptions = {
|
|
65
69
|
readonly delivery?: boolean;
|
|
66
70
|
readonly audit?: boolean;
|
|
67
71
|
readonly jobs?: boolean;
|
|
68
|
-
readonly sessions?: boolean;
|
|
69
72
|
readonly rateLimiting?: boolean;
|
|
70
73
|
};
|
|
71
74
|
|
|
@@ -135,13 +138,11 @@ export function composeOpsStack(options: OpsStackOptions = {}): FeatureDefinitio
|
|
|
135
138
|
const delivery = options.delivery ?? true;
|
|
136
139
|
const audit = options.audit ?? true;
|
|
137
140
|
const jobs = options.jobs ?? true;
|
|
138
|
-
const sessions = options.sessions ?? false;
|
|
139
141
|
const rateLimiting = options.rateLimiting ?? false;
|
|
140
142
|
const out: FeatureDefinition[] = [];
|
|
141
143
|
if (delivery) out.push(createDeliveryFeature());
|
|
142
144
|
if (audit) out.push(createAuditFeature());
|
|
143
145
|
if (jobs) out.push(createJobsFeature());
|
|
144
|
-
if (sessions) out.push(createSessionsFeature());
|
|
145
146
|
if (rateLimiting) out.push(createRateLimitingFeature());
|
|
146
147
|
return out;
|
|
147
148
|
}
|
|
@@ -670,6 +670,12 @@ export async function createKumikoServer(
|
|
|
670
670
|
...(options.effectiveFeatures !== undefined && {
|
|
671
671
|
effectiveFeatures: options.effectiveFeatures,
|
|
672
672
|
}),
|
|
673
|
+
// Wires ctx.jobRunner so write handlers' `ctx.jobRunner.dispatch(...)`
|
|
674
|
+
// works under the dev server (#983) — no-ops when no jobs are
|
|
675
|
+
// registered. `jobs: {}` (no `consumerLane`) builds an enqueuer-only
|
|
676
|
+
// runner — `startDevJobRunners` below stays the sole consumer/cron-
|
|
677
|
+
// scheduler for every lane, so `runOnBoot`/cron jobs don't double-fire.
|
|
678
|
+
jobs: {},
|
|
673
679
|
});
|
|
674
680
|
await createEventsTable(stack.db);
|
|
675
681
|
await pushEntityProjectionTables(stack, stack.registry);
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { makeAuthPaths } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
|
|
2
|
+
import { createSmtpTransportFromEnv } from "@cosmicdrift/kumiko-bundled-features/channel-email";
|
|
3
|
+
import {
|
|
4
|
+
buildEnvConfigOverrides,
|
|
5
|
+
createConfigAccessorFactory,
|
|
6
|
+
createConfigResolver,
|
|
7
|
+
} from "@cosmicdrift/kumiko-bundled-features/config";
|
|
8
|
+
import {
|
|
9
|
+
collectChannels,
|
|
10
|
+
createDeliveryService,
|
|
11
|
+
DELIVERY_FEATURE,
|
|
12
|
+
} from "@cosmicdrift/kumiko-bundled-features/delivery";
|
|
13
|
+
import {
|
|
14
|
+
createSecretsContext,
|
|
15
|
+
SECRETS_FEATURE_NAME,
|
|
16
|
+
} from "@cosmicdrift/kumiko-bundled-features/secrets";
|
|
17
|
+
import {
|
|
18
|
+
bindAutoRevokeFromFeature,
|
|
19
|
+
createSessionCallbacks,
|
|
20
|
+
} from "@cosmicdrift/kumiko-bundled-features/sessions";
|
|
21
|
+
import { createTextContentApi } from "@cosmicdrift/kumiko-bundled-features/text-content";
|
|
22
|
+
import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
|
|
23
|
+
import type { KmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
|
|
24
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
25
|
+
import type {
|
|
26
|
+
ConfigResolver,
|
|
27
|
+
FeatureDefinition,
|
|
28
|
+
NotifyFactory,
|
|
29
|
+
Registry,
|
|
30
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
31
|
+
import type { MasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
|
|
32
|
+
import { type BootCrypto, resolveBootCrypto } from "./boot/boot-crypto";
|
|
33
|
+
import type {
|
|
34
|
+
AuthMailOptions,
|
|
35
|
+
EmailVerificationSetup,
|
|
36
|
+
InviteSetup,
|
|
37
|
+
PasswordResetSetup,
|
|
38
|
+
SignupSetup,
|
|
39
|
+
} from "./run-prod-app";
|
|
40
|
+
import type { ProdSessionsConfig } from "./session-wiring";
|
|
41
|
+
|
|
42
|
+
// Boot-time context helpers for runProdApp: ctx-extra-context wiring
|
|
43
|
+
// (textContent/delivery/secrets/config-resolver), auth-mail convenience
|
|
44
|
+
// normalization, and prod session-auth wiring. Split out of run-prod-app.ts
|
|
45
|
+
// (#1005, Welle 2) — mechanical relocation, these are self-contained pure
|
|
46
|
+
// functions, no closure over runProdApp's local boot state.
|
|
47
|
+
|
|
48
|
+
// Shared with runDevApp (mergeConfigResolverDefault) for dev/prod parity.
|
|
49
|
+
export function addConfigAccessorFactory<T extends { readonly configResolver?: ConfigResolver }>(
|
|
50
|
+
resolved: T,
|
|
51
|
+
registry: Registry,
|
|
52
|
+
): T {
|
|
53
|
+
if (!resolved.configResolver) return resolved;
|
|
54
|
+
return {
|
|
55
|
+
...resolved,
|
|
56
|
+
_configAccessorFactory: createConfigAccessorFactory(registry, resolved.configResolver),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Framework-Default-Provider für den AppContext — gleicher Mechanismus wie
|
|
61
|
+
// der tenantTierResolver-Autowire (findTierResolverUsage): deklarierter
|
|
62
|
+
// Bedarf (Feature gemountet) → Default aus db/env, App überschreibt nur die
|
|
63
|
+
// Ausnahme. textContent ist unbedingt (createTextContentApi wirft nie, baut
|
|
64
|
+
// nur einen db-gebundenen Accessor). secrets wird nur auto-verdrahtet wenn
|
|
65
|
+
// das secrets-Feature gemountet ist UND ein KEK tatsächlich verfügbar ist
|
|
66
|
+
// (masterKey-Override ODER env-KEK present) — sonst skip, damit der eager
|
|
67
|
+
// KEK-Detection + Provider/Cipher-Aufbau leben in boot/boot-crypto.ts
|
|
68
|
+
// (envHasMasterKek, resolveBootCrypto) — gemeinsam mit runDevApp.
|
|
69
|
+
// Prod-Misconfig (secrets gemountet, kein KEK) fängt schon secretsEnvSchema
|
|
70
|
+
// beim Boot; fehlt der env-Schema-Pfad, wirft requireSecretsContext beim
|
|
71
|
+
// ersten ctx.secrets-Zugriff mit Wiring-Hinweis. configResolver nur im
|
|
72
|
+
// Auth-Mode. Exportiert + pure für Unit-Tests; der merge mit App-Werten
|
|
73
|
+
// passiert beim Caller (App gewinnt).
|
|
74
|
+
|
|
75
|
+
// Prod/dev parity for ctx.notify: without this `_notifyFactory` is only wired
|
|
76
|
+
// in tests (createDeliveryTestContext), so ctx.notify is undefined at runtime
|
|
77
|
+
// and every notification silently skips. sseBroker optional (email/push don't
|
|
78
|
+
// need it, in-app SSE does); no jobRunner → queued channels send inline.
|
|
79
|
+
function buildDeliveryNotifyFactory(opts: {
|
|
80
|
+
readonly db: DbConnection;
|
|
81
|
+
readonly registry: Registry;
|
|
82
|
+
readonly sseBroker?: SseBroker;
|
|
83
|
+
}): NotifyFactory {
|
|
84
|
+
const deliveryService = createDeliveryService({
|
|
85
|
+
db: opts.db,
|
|
86
|
+
registry: opts.registry,
|
|
87
|
+
channels: collectChannels(opts.registry),
|
|
88
|
+
...(opts.sseBroker && { sseBroker: opts.sseBroker }),
|
|
89
|
+
});
|
|
90
|
+
return (user, tenantId) => (notificationType, options) =>
|
|
91
|
+
deliveryService.notify(notificationType, options, user, tenantId);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function buildBootExtraContext(opts: {
|
|
95
|
+
readonly db: DbConnection;
|
|
96
|
+
readonly features: readonly FeatureDefinition[];
|
|
97
|
+
readonly envSource: Record<string, string | undefined>;
|
|
98
|
+
readonly registry: Registry;
|
|
99
|
+
readonly hasAuth: boolean;
|
|
100
|
+
// Resolved once per boot via resolveBootCrypto — shared by secrets,
|
|
101
|
+
// config-resolver, config-set-handler and boot-seeds. Absent (tests
|
|
102
|
+
// that don't care about encryption) ⇒ resolved from envSource here.
|
|
103
|
+
readonly crypto?: BootCrypto;
|
|
104
|
+
readonly masterKey?: MasterKeyProvider;
|
|
105
|
+
readonly sseBroker?: SseBroker;
|
|
106
|
+
readonly kms?: KmsAdapter;
|
|
107
|
+
}): Record<string, unknown> {
|
|
108
|
+
const crypto = opts.crypto ?? resolveBootCrypto(opts.envSource, opts.masterKey);
|
|
109
|
+
const hasSecretsFeature = opts.features.some((f) => f.name === SECRETS_FEATURE_NAME);
|
|
110
|
+
const wireSecrets = hasSecretsFeature && crypto.masterKeyProvider !== undefined;
|
|
111
|
+
const hasDeliveryFeature = opts.features.some((f) => f.name === DELIVERY_FEATURE);
|
|
112
|
+
return {
|
|
113
|
+
textContent: createTextContentApi(opts.db),
|
|
114
|
+
...(opts.kms && { kms: opts.kms }),
|
|
115
|
+
...(hasDeliveryFeature && {
|
|
116
|
+
_notifyFactory: buildDeliveryNotifyFactory({
|
|
117
|
+
db: opts.db,
|
|
118
|
+
registry: opts.registry,
|
|
119
|
+
...(opts.sseBroker && { sseBroker: opts.sseBroker }),
|
|
120
|
+
}),
|
|
121
|
+
}),
|
|
122
|
+
// Top-level provider so feature jobs (secrets rotate, config reencrypt)
|
|
123
|
+
// reach it via ctx — previously only test-stack wired it.
|
|
124
|
+
...(crypto.masterKeyProvider && { masterKeyProvider: crypto.masterKeyProvider }),
|
|
125
|
+
// Encrypt/decrypt partner for `encrypted: true` config keys. Wired
|
|
126
|
+
// whenever a master key exists — NOT gated on the secrets feature,
|
|
127
|
+
// config encryption must work without mounting ctx.secrets.
|
|
128
|
+
...(crypto.configCipher && { configEncryption: crypto.configCipher }),
|
|
129
|
+
...(wireSecrets &&
|
|
130
|
+
crypto.masterKeyProvider && {
|
|
131
|
+
secrets: createSecretsContext({
|
|
132
|
+
db: opts.db,
|
|
133
|
+
masterKeyProvider: crypto.masterKeyProvider,
|
|
134
|
+
dekCache: crypto.dekCache,
|
|
135
|
+
}),
|
|
136
|
+
}),
|
|
137
|
+
...(opts.hasAuth && {
|
|
138
|
+
configResolver: createConfigResolver({
|
|
139
|
+
appOverrides: buildEnvConfigOverrides(opts.registry, opts.envSource),
|
|
140
|
+
...(crypto.configCipher && { cipher: crypto.configCipher }),
|
|
141
|
+
}),
|
|
142
|
+
}),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// auth.mail-Convenience → normalisiert in die expliziten passwordReset/
|
|
147
|
+
// emailVerification/signup/invite-Felder, BEVOR buildComposeAuthOptions
|
|
148
|
+
// (Feature-Side: hmacSecret/mode) und das auth-routes-Fragment sie lesen —
|
|
149
|
+
// so speist EIN mail-Block beide Pfade. App-explizite Flows gewinnen über
|
|
150
|
+
// den Default. Null-Transport-Guard: ohne SMTP_HOST-env bleibt alles
|
|
151
|
+
// unverdrahtet (sonst lieferten die reset/verify-Routes 500).
|
|
152
|
+
/** Die Auth-Felder die resolveAuthMail liest/normalisiert — beide
|
|
153
|
+
* App-Auth-Typen (prod + dev) erfüllen das strukturell. */
|
|
154
|
+
type AuthMailNormalizable = {
|
|
155
|
+
readonly mail?: AuthMailOptions;
|
|
156
|
+
readonly passwordReset?: PasswordResetSetup;
|
|
157
|
+
readonly emailVerification?: EmailVerificationSetup;
|
|
158
|
+
readonly signup?: SignupSetup;
|
|
159
|
+
readonly invite?: InviteSetup;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
export function resolveAuthMail<T extends AuthMailNormalizable>(
|
|
163
|
+
auth: T,
|
|
164
|
+
hmacSecret: string,
|
|
165
|
+
envSource: Record<string, string | undefined>,
|
|
166
|
+
): T {
|
|
167
|
+
if (!auth.mail) return auth;
|
|
168
|
+
// SMTP-presence gate: ohne SMTP_HOST-env wird KEIN Flow verdrahtet (Routes
|
|
169
|
+
// blieben sonst 500). Der eigentliche Mail-Versand läuft über delivery
|
|
170
|
+
// (channel-email), nicht über diesen Transport — er ist nur der Detektor
|
|
171
|
+
// "ist Mail konfiguriert?".
|
|
172
|
+
if (
|
|
173
|
+
!createSmtpTransportFromEnv(envSource, { fallbackFrom: auth.mail.from ?? "noreply@localhost" })
|
|
174
|
+
) {
|
|
175
|
+
return auth;
|
|
176
|
+
}
|
|
177
|
+
const paths = makeAuthPaths(auth.mail.paths);
|
|
178
|
+
// appName/locale fließen in alle vier Flow-Options (alle mailen via delivery).
|
|
179
|
+
const mailPresentation = {
|
|
180
|
+
...(auth.mail.appName !== undefined && { appName: auth.mail.appName }),
|
|
181
|
+
...(auth.mail.locale !== undefined && { locale: auth.mail.locale }),
|
|
182
|
+
};
|
|
183
|
+
return {
|
|
184
|
+
...auth,
|
|
185
|
+
passwordReset: auth.passwordReset ?? {
|
|
186
|
+
hmacSecret,
|
|
187
|
+
appUrl: `${auth.mail.baseUrl}${paths.resetPassword}`,
|
|
188
|
+
...mailPresentation,
|
|
189
|
+
},
|
|
190
|
+
emailVerification: auth.emailVerification ?? {
|
|
191
|
+
hmacSecret,
|
|
192
|
+
appUrl: `${auth.mail.baseUrl}${paths.verifyEmail}`,
|
|
193
|
+
...(auth.mail.emailVerificationMode !== undefined && {
|
|
194
|
+
mode: auth.mail.emailVerificationMode,
|
|
195
|
+
}),
|
|
196
|
+
...mailPresentation,
|
|
197
|
+
},
|
|
198
|
+
signup: auth.signup ?? {
|
|
199
|
+
appUrl: `${auth.mail.baseUrl}${paths.signupComplete}`,
|
|
200
|
+
...mailPresentation,
|
|
201
|
+
},
|
|
202
|
+
invite: auth.invite ?? {
|
|
203
|
+
appUrl: `${auth.mail.baseUrl}${paths.inviteAccept}`,
|
|
204
|
+
...mailPresentation,
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function buildProdSessionAuth(
|
|
210
|
+
db: DbConnection,
|
|
211
|
+
opts: ProdSessionsConfig,
|
|
212
|
+
sessionsFeature: FeatureDefinition | undefined,
|
|
213
|
+
): {
|
|
214
|
+
readonly sessionCreator: ReturnType<typeof createSessionCallbacks>["sessionCreator"];
|
|
215
|
+
readonly sessionRevoker: ReturnType<typeof createSessionCallbacks>["sessionRevoker"];
|
|
216
|
+
readonly sessionChecker: ReturnType<typeof createSessionCallbacks>["sessionChecker"];
|
|
217
|
+
readonly sessionStrictMode: true;
|
|
218
|
+
} {
|
|
219
|
+
const cbs = createSessionCallbacks({
|
|
220
|
+
db,
|
|
221
|
+
...(opts.expiresInMs !== undefined && { expiresInMs: opts.expiresInMs }),
|
|
222
|
+
});
|
|
223
|
+
// Secure-by-default: password-change/-reset mass-revokes the user's live
|
|
224
|
+
// sessions without the app opting in via autoRevokeOnPasswordChange.
|
|
225
|
+
if (sessionsFeature) {
|
|
226
|
+
bindAutoRevokeFromFeature(sessionsFeature)?.(cbs.sessionMassRevoker);
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
sessionCreator: cbs.sessionCreator,
|
|
230
|
+
sessionRevoker: cbs.sessionRevoker,
|
|
231
|
+
sessionChecker: cbs.sessionChecker,
|
|
232
|
+
sessionStrictMode: true,
|
|
233
|
+
};
|
|
234
|
+
}
|