@cosmicdrift/kumiko-server-runtime 0.366.0 → 0.367.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.
@@ -1,5 +1,6 @@
1
1
  import { type EmailVerificationOptions, type PasswordResetOptions } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
2
2
  import { resolveSessionStore } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
3
+ import { type UnsubscribeLinkConfig } from "@cosmicdrift/kumiko-bundled-features/delivery";
3
4
  import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
4
5
  import type { KmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
5
6
  import { type DbConnection } from "@cosmicdrift/kumiko-framework/db";
@@ -30,6 +31,8 @@ export declare function buildBootExtraContext(opts: {
30
31
  readonly deliverQueuedInline?: boolean;
31
32
  /** Receives redacted delivery failures; without it they go to the console. */
32
33
  readonly log?: Logger;
34
+ /** Signs `unsubscribe: true` notifications' mail links; see runProdApp `unsubscribe`. */
35
+ readonly unsubscribeLinks?: UnsubscribeLinkConfig;
33
36
  }): Record<string, unknown>;
34
37
  /** Die Auth-Felder die resolveAuthMail liest/normalisiert — beide
35
38
  * App-Auth-Typen (prod + dev) erfüllen das strukturell. */
@@ -60,6 +60,7 @@ function buildDeliveryNotifyFactory(opts) {
60
60
  ...(opts.escapeHatchAuditSink && { escapeHatchAuditSink: opts.escapeHatchAuditSink }),
61
61
  ...(opts.log && { log: opts.log }),
62
62
  ...(opts.tenantUserIdsQuery && { tenantUserIdsQuery: opts.tenantUserIdsQuery }),
63
+ ...(opts.unsubscribeLinks && { unsubscribeLinks: opts.unsubscribeLinks }),
63
64
  ...(configAccessorFactory && {
64
65
  tenantConfigFor: (tenantId) => {
65
66
  const systemUser = createSystemUser(tenantId);
@@ -147,6 +148,7 @@ export function buildBootExtraContext(opts) {
147
148
  ...(hasTenantFeature && { tenantUserIdsQuery: TenantQueries.resolveUserIds }),
148
149
  configResolver: bootConfigResolver,
149
150
  deliverQueuedInline: opts.deliverQueuedInline,
151
+ unsubscribeLinks: opts.unsubscribeLinks,
150
152
  }),
151
153
  }),
152
154
  // Top-level provider so feature jobs (secrets rotate, config reencrypt)
@@ -1,6 +1,7 @@
1
1
  /// <reference types="bun-types" preserve="true" />
2
2
  import { type AccountLockoutOptions, type AccountUnlockOptions, type AuthMailLocale, type AuthPaths, type EmailVerificationOptions, type InviteOptions, type PasswordResetOptions, type SignupOptions } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
3
3
  import { type SeedAdminOptions } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/seeding";
4
+ import { type UnsubscribeLinkConfig } from "@cosmicdrift/kumiko-bundled-features/delivery";
4
5
  import { type ExtraRouteDefinition, type KumikoServeEnv, type PostAuthLandingResolver, type SseBroker } from "@cosmicdrift/kumiko-framework/api";
5
6
  import { type KmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
6
7
  import { type EffectiveFeaturesResolver, type FeatureDefinition, type ValidateBootOptions } from "@cosmicdrift/kumiko-framework/engine";
@@ -14,6 +15,7 @@ import type { PageHeadMeta, PageHeadResolver, PageHeadSystemQuery } from "@cosmi
14
15
  import { Redis } from "ioredis";
15
16
  import { type SystemWireDeps } from "./extra-routes-deps.js";
16
17
  import { type SecurityHeadersOption } from "./security-headers.js";
18
+ import { type SeedCreateFileRef } from "./seed-dispatcher.js";
17
19
  export { buildBunServeOptions } from "./bun-serve-options.js";
18
20
  export { addConfigAccessorFactory, buildBootExtraContext, loggerFromExtraContext, resolveAuthMail, } from "./run-prod-app-boot-context.js";
19
21
  export { staticCachePolicy } from "./run-prod-app-static-files.js";
@@ -175,6 +177,9 @@ export type ProdSeedFn = (deps: {
175
177
  db: import("@cosmicdrift/kumiko-framework/db").DbConnection;
176
178
  registry: import("@cosmicdrift/kumiko-framework/engine").Registry;
177
179
  dispatcher: import("@cosmicdrift/kumiko-framework/pipeline").Dispatcher;
180
+ /** Stores bytes as a fileRef of `tenantId` (same validation and storage
181
+ * layout as POST /files) without a helper write handler. */
182
+ createFileRef: SeedCreateFileRef;
178
183
  }) => Promise<void>;
179
184
  /** Boot-Time-Deps die `extraContext` + `anonymousAccess` Factories als
180
185
  * Argument bekommen. Closure dann in der returned Config (z.B. ein
@@ -392,6 +397,22 @@ export type RunProdAppOptions = {
392
397
  * inside buildServer, right after the r.httpRoute loop, before seeds
393
398
  * (kumiko-framework#3050). */
394
399
  readonly extraRoutes?: readonly ExtraRouteDefinition[];
400
+ /** Framework-managed unsubscribe links. Mounts `createUnsubscribeRoutes({ secret })` and makes
401
+ * delivery sign `data.unsubscribeUrl` (path `DELIVERY_UNSUBSCRIBE_PATH`) for every email
402
+ * notification declared with `unsubscribe: true`, for user and `route` recipients; a link the
403
+ * caller put into `data.unsubscribeUrl` wins. `secret` must be dedicated (≥32 chars), not the
404
+ * session JWT secret. Links point at the origin of `auth.publicOrigin`, then `auth.mail.baseUrl`,
405
+ * then the `APP_BASE_URL` env; with none of them no link is added (boot warning). Address
406
+ * recipients need a blind-index key, otherwise they get no link. Do not also mount
407
+ * `createUnsubscribeRoutes` via `extraRoutes`. `tenantOrigin` points a tenant's links at
408
+ * its own subdomain or custom domain (the host must serve this app's `/api` routes); null,
409
+ * a throw or a non-https value fall back to the app origin. The link carries the mail's
410
+ * locale, so the confirmation pages speak the mail's language. An app reacts to an address
411
+ * opt-out with an `r.job` triggered on `DeliveryHandlers.unsubscribeAddress`. */
412
+ readonly unsubscribe?: {
413
+ readonly secret: string;
414
+ readonly tenantOrigin?: UnsubscribeLinkConfig["tenantOrigin"];
415
+ };
395
416
  /** Hook for app-wired co-running components that need the system-write
396
417
  * dispatcher (e.g. wiring a webhook plugin's system-scoped secrets, or
397
418
  * starting a co-running supervisor) — runs after buildServer, before
@@ -43,12 +43,13 @@ import { AuthErrors, AuthHandlers, AuthQueries, } from "@cosmicdrift/kumiko-bund
43
43
  import { seedAdminGuarded, } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/seeding";
44
44
  import { EXT_SESSION_STORE, EXT_TOKEN_VERIFIER, resolveAnonymousAccessFromRegistry, resolveTokenVerifier, } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
45
45
  import { AUTH_MFA_FEATURE, AuthMfaHandlers } from "@cosmicdrift/kumiko-bundled-features/auth-mfa";
46
+ import { createUnsubscribeRoutes, } from "@cosmicdrift/kumiko-bundled-features/delivery";
46
47
  import { PAT_FEATURE, patRateLimitFromFeature, } from "@cosmicdrift/kumiko-bundled-features/personal-access-tokens";
47
48
  import { SECRETS_MASTER_KEK_ENV_KEYS } from "@cosmicdrift/kumiko-bundled-features/secrets";
48
49
  import { SESSIONS_FEATURE } from "@cosmicdrift/kumiko-bundled-features/sessions";
49
50
  import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
50
51
  import { UserQueries } from "@cosmicdrift/kumiko-bundled-features/user";
51
- import { createDefaultSseBroker, createRedisLoginRateLimiter, loadJwtSecretOrKeyring, parseTrustedProxyHopsEnv, TRUSTED_PROXY_HOPS_ENV, withFileProviderResolver, } from "@cosmicdrift/kumiko-framework/api";
52
+ import { createDefaultSseBroker, createRedisLoginRateLimiter, loadJwtSecretOrKeyring, parseTrustedProxyHopsEnv, publicOriginOf, TRUSTED_PROXY_HOPS_ENV, withFileProviderResolver, } from "@cosmicdrift/kumiko-framework/api";
52
53
  import { configureBlindIndexKey, configurePiiSubjectKms, resolvePlatformKeks, } from "@cosmicdrift/kumiko-framework/crypto";
53
54
  import { configureEntityFieldEncryption, createDbConnection, dbConnectionOptionsFromEnv, outsideTransactionPoolOptionsFromEnv, } from "@cosmicdrift/kumiko-framework/db";
54
55
  import { collectWriteHandlerQns, createRegistry, findTierResolverUsage, requireTierResolverPlugin, validateAppCustomScreenWriteQns, validateBoot, } from "@cosmicdrift/kumiko-framework/engine";
@@ -57,6 +58,7 @@ import { KumikoBootError, kmsSlotsOf, parseEnv, withOptionalEnvKeys, } from "@co
57
58
  import { renderDryRun } from "@cosmicdrift/kumiko-framework/env/dry-run";
58
59
  import { createEsOperationsTable, createSeedMigrationContext, runPendingSeedMigrations, } from "@cosmicdrift/kumiko-framework/es-ops";
59
60
  import { assertSeedModeNotInProduction } from "@cosmicdrift/kumiko-framework/event-store";
61
+ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
60
62
  import { assertKumikoSchemaCurrent, SchemaDriftError, } from "@cosmicdrift/kumiko-framework/migrations";
61
63
  import { createMetricsForFactory, } from "@cosmicdrift/kumiko-framework/observability";
62
64
  import { createDispatcher, createEntityCache, createEventDedup, createIdempotencyGuard, flushEscapeHatchAudits, } from "@cosmicdrift/kumiko-framework/pipeline";
@@ -73,10 +75,11 @@ import { buildBunServeOptions, resolveDerivedMaxRequestBodySize } from "./bun-se
73
75
  import { buildComposeAuthOptions, composeFeatures } from "./compose-features.js";
74
76
  import { makeDispatchSystemWrite } from "./extra-routes-deps.js";
75
77
  import { assertPiiBootInvariants } from "./pii-boot-gate.js";
76
- import { resolveAuthPublicOrigin } from "./resolve-auth-public-origin.js";
78
+ import { APP_BASE_URL_ENV, resolveAuthPublicOrigin } from "./resolve-auth-public-origin.js";
77
79
  import { addConfigAccessorFactory, buildBootExtraContext, buildProdSessionAuth, loggerFromExtraContext, resolveAuthMail, wireProdPatAutoRevoke, } from "./run-prod-app-boot-context.js";
78
80
  import { buildStaticFallback } from "./run-prod-app-static-files.js";
79
81
  import { withSecurityHeaders } from "./security-headers.js";
82
+ import { makeSeedCreateFileRef, withSeedErrorContext, } from "./seed-dispatcher.js";
80
83
  import { assertSessionBootInvariants } from "./session-boot-gate.js";
81
84
  import { shouldWireProdSessions } from "./session-wiring.js";
82
85
  import { stripNoRouteMatchHeader } from "./try-hono-first.js";
@@ -288,6 +291,27 @@ export async function runProdApp(options) {
288
291
  console.warn(message);
289
292
  })
290
293
  : undefined;
294
+ const unsubscribeBaseUrl = options.unsubscribe
295
+ ? publicOriginOf(effectiveAuth?.publicOrigin ??
296
+ effectiveAuth?.mail?.baseUrl ??
297
+ envSource[APP_BASE_URL_ENV] ??
298
+ "")
299
+ : undefined;
300
+ if (options.unsubscribe && unsubscribeBaseUrl === undefined) {
301
+ createFallbackLogger("runProdApp").warn(`unsubscribe is enabled but no valid public app URL is known (auth.publicOrigin, auth.mail.baseUrl or ${APP_BASE_URL_ENV}), so mails get no unsubscribe link.`);
302
+ }
303
+ const unsubscribeLinks = options.unsubscribe && unsubscribeBaseUrl !== undefined
304
+ ? {
305
+ secret: options.unsubscribe.secret,
306
+ baseUrl: unsubscribeBaseUrl,
307
+ ...(options.unsubscribe.tenantOrigin !== undefined && {
308
+ tenantOrigin: options.unsubscribe.tenantOrigin,
309
+ }),
310
+ }
311
+ : undefined;
312
+ const unsubscribeRoutes = options.unsubscribe
313
+ ? createUnsubscribeRoutes({ secret: options.unsubscribe.secret })
314
+ : [];
291
315
  // 3. Feature registry. Auth-mode auto-mixes config/user/tenant/auth-email-
292
316
  // password via composeFeatures — same source-of-truth as runDevApp
293
317
  // AND the per-app drizzle-Schema-Generator, so Migration und Runtime
@@ -452,6 +476,7 @@ export async function runProdApp(options) {
452
476
  crypto: bootCrypto,
453
477
  ...(options.kms && { kms: options.kms }),
454
478
  ...(bootLogger && { log: bootLogger }),
479
+ ...(unsubscribeLinks && { unsubscribeLinks }),
455
480
  });
456
481
  const extraContext = {
457
482
  ...addConfigAccessorFactory({ ...autoExtraContext, ...resolvedExtraContext }, registry),
@@ -534,7 +559,9 @@ export async function runProdApp(options) {
534
559
  ...(options.rateLimit && { rateLimit: options.rateLimit }),
535
560
  ...(trustedProxyHops !== undefined && { trustedProxyHops }),
536
561
  ...(options.errorDocs && { errorDocs: options.errorDocs }),
537
- ...(options.extraRoutes && { extraRoutes: options.extraRoutes }),
562
+ ...((options.extraRoutes || options.unsubscribe) && {
563
+ extraRoutes: [...(options.extraRoutes ?? []), ...unsubscribeRoutes],
564
+ }),
538
565
  ...(effectiveAuth && {
539
566
  auth: {
540
567
  membershipQuery: TenantQueries.memberships,
@@ -703,12 +730,20 @@ export async function runProdApp(options) {
703
730
  db,
704
731
  ...(bootCrypto.configCipher && { cipher: bootCrypto.configCipher }),
705
732
  });
706
- const seedDispatcher = (options.seeds?.length ?? 0) > 0 || options.seedsDir !== undefined
707
- ? createDispatcher(registry, withFileProviderResolver(registry, { db, redis, entityCache, registry, ...extraContext }, cacheSync))
733
+ const hasSeeds = (options.seeds?.length ?? 0) > 0 || options.seedsDir !== undefined;
734
+ const seedContext = hasSeeds
735
+ ? withFileProviderResolver(registry, { db, redis, entityCache, registry, ...extraContext }, cacheSync)
736
+ : undefined;
737
+ const seedDispatcher = seedContext
738
+ ? withSeedErrorContext(createDispatcher(registry, seedContext, {
739
+ ...baseEntrypointOptions.dispatcherOptions,
740
+ ...(entrypoint.mode === "all-in-one" && { jobRunner: entrypoint.jobRunner }),
741
+ }))
708
742
  : undefined;
709
- if (seedDispatcher) {
743
+ if (seedDispatcher && seedContext) {
744
+ const createFileRef = makeSeedCreateFileRef({ db, registry, context: seedContext });
710
745
  for (const seed of options.seeds ?? []) {
711
- await seed({ db, registry, dispatcher: seedDispatcher });
746
+ await seed({ db, registry, dispatcher: seedDispatcher, createFileRef });
712
747
  }
713
748
  }
714
749
  // ES-Operations / Seed-Migrations (Phase 1). Läuft NACH applyBootSeeds +
@@ -0,0 +1,11 @@
1
+ import { type DbConnection } from "@cosmicdrift/kumiko-framework/db";
2
+ import { type AppContext, type Registry, type TenantId, type WriteResult } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { type CreatedFileRef, type CreateFileRefInput } from "@cosmicdrift/kumiko-framework/files";
4
+ import type { Dispatcher } from "@cosmicdrift/kumiko-framework/pipeline";
5
+ export type SeedCreateFileRef = (tenantId: TenantId, input: CreateFileRefInput) => Promise<WriteResult<CreatedFileRef>>;
6
+ export declare function makeSeedCreateFileRef(deps: {
7
+ readonly db: DbConnection;
8
+ readonly registry: Registry;
9
+ readonly context: AppContext;
10
+ }): SeedCreateFileRef;
11
+ export declare function withSeedErrorContext<D extends Pick<Dispatcher, "write">>(dispatcher: D): D;
@@ -0,0 +1,36 @@
1
+ import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
2
+ import { createSystemUser, } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { reraiseAsKumikoError } from "@cosmicdrift/kumiko-framework/errors";
4
+ import { createFileContext, createFileRefFromBytes, } from "@cosmicdrift/kumiko-framework/files";
5
+ export function makeSeedCreateFileRef(deps) {
6
+ const { db, registry, context } = deps;
7
+ const fileResolver = context._fileProviderResolver;
8
+ return (tenantId, input) => createFileRefFromBytes({
9
+ db: createTenantDb(db, tenantId),
10
+ files: fileResolver
11
+ ? createFileContext(() => fileResolver(tenantId), tenantId)
12
+ : context.files,
13
+ registry,
14
+ user: createSystemUser(tenantId),
15
+ }, input);
16
+ }
17
+ // The dispatcher masks a thrown handler error as "internal error" and keeps the
18
+ // cause only on a side channel; a seed has no HTTP client to protect, so the
19
+ // handler name and cause message go into the message the seed caller sees.
20
+ export function withSeedErrorContext(dispatcher) {
21
+ return {
22
+ ...dispatcher,
23
+ async write(type, payload, user, requestId) {
24
+ const result = await dispatcher.write(type, payload, user, requestId);
25
+ if (result.isSuccess || result.error.code !== "internal_error")
26
+ return result;
27
+ const cause = reraiseAsKumikoError(result.error).cause;
28
+ const causeMessage = cause instanceof Error ? cause.message : undefined;
29
+ const detail = causeMessage ? `${type}: ${causeMessage}` : type;
30
+ return {
31
+ isSuccess: false,
32
+ error: { ...result.error, message: `${result.error.message} (${detail})` },
33
+ };
34
+ },
35
+ };
36
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-server-runtime",
3
- "version": "0.366.0",
3
+ "version": "0.367.0",
4
4
  "description": "Production server-boot runtime for Kumiko apps: connections, schema-drift-gate, seeds, lifecycle, graceful shutdown. Symmetric to kumiko-dev-server's runDevApp, without dev/scaffold/codegen tooling.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -84,12 +84,12 @@
84
84
  }
85
85
  },
86
86
  "dependencies": {
87
- "@cosmicdrift/kumiko-bundled-features": "0.366.0",
88
- "@cosmicdrift/kumiko-framework": "0.366.0",
89
- "@cosmicdrift/kumiko-headless": "0.366.0",
90
- "@cosmicdrift/kumiko-renderer-mail-html": "0.366.0",
91
- "@cosmicdrift/kumiko-renderer-web": "0.366.0",
92
- "@cosmicdrift/kumiko-types": "0.366.0",
87
+ "@cosmicdrift/kumiko-bundled-features": "0.367.0",
88
+ "@cosmicdrift/kumiko-framework": "0.367.0",
89
+ "@cosmicdrift/kumiko-headless": "0.367.0",
90
+ "@cosmicdrift/kumiko-renderer-mail-html": "0.367.0",
91
+ "@cosmicdrift/kumiko-renderer-web": "0.367.0",
92
+ "@cosmicdrift/kumiko-types": "0.367.0",
93
93
  "bun-types": "^1.3.13",
94
94
  "ioredis": "^5.10.1",
95
95
  "temporal-polyfill": "^0.3.2"