@cosmicdrift/kumiko-server-runtime 0.371.0 → 0.372.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.
@@ -181,7 +181,6 @@ export async function bootWorkerProcess(options, envSource, profile) {
181
181
  },
182
182
  eventDedup,
183
183
  ...(options.observability && { observability: options.observability }),
184
- ...(options.observabilityOptions && { observabilityOptions: options.observabilityOptions }),
185
184
  redisUrl,
186
185
  ...jobLogger,
187
186
  ...(options.jobs?.queueNamePrefix !== undefined && {
@@ -1,3 +1,4 @@
1
+ import type { SystemDispatchArgs } from "@cosmicdrift/kumiko-framework/api";
1
2
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
2
3
  import type { Registry, WriteResult } from "@cosmicdrift/kumiko-framework/engine";
3
4
  import type { MetricsHandle } from "@cosmicdrift/kumiko-framework/observability";
@@ -30,4 +31,7 @@ export type SystemWireDeps = {
30
31
  readonly payload: unknown;
31
32
  readonly tenantId: import("@cosmicdrift/kumiko-framework/engine").TenantId;
32
33
  }) => Promise<WriteResult>;
34
+ /** Read counterpart of dispatchSystemWrite, same SystemAdmin scope. Hand
35
+ * anonymous routes only a narrow function built from it, never this. */
36
+ readonly dispatchSystemQuery: (args: SystemDispatchArgs) => Promise<unknown>;
33
37
  };
@@ -5,7 +5,7 @@ import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
5
5
  import type { KmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
6
6
  import { type DbConnection } from "@cosmicdrift/kumiko-framework/db";
7
7
  import { type ConfigResolver, type FeatureDefinition, type Registry } from "@cosmicdrift/kumiko-framework/engine";
8
- import type { Logger } from "@cosmicdrift/kumiko-framework/logging";
8
+ import { type Logger } from "@cosmicdrift/kumiko-framework/logging";
9
9
  import type { MasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
10
10
  import type { Redis } from "ioredis";
11
11
  import { type BootCrypto } from "./boot/boot-crypto.ts";
@@ -14,7 +14,7 @@ export declare function addConfigAccessorFactory<T extends {
14
14
  readonly configResolver?: ConfigResolver;
15
15
  }>(resolved: T, registry: Registry): T;
16
16
  export declare function loggerFromExtraContext(extraContext: Record<string, unknown>): Logger | undefined;
17
- export declare function buildBootExtraContext(opts: {
17
+ type BootExtraContextOptions = {
18
18
  readonly db: DbConnection;
19
19
  readonly features: readonly FeatureDefinition[];
20
20
  readonly envSource: Record<string, string | undefined>;
@@ -33,7 +33,8 @@ export declare function buildBootExtraContext(opts: {
33
33
  readonly log?: Logger;
34
34
  /** Signs `unsubscribe: true` notifications' mail links; see runProdApp `unsubscribe`. */
35
35
  readonly unsubscribeLinks?: UnsubscribeLinkConfig;
36
- }): Record<string, unknown>;
36
+ };
37
+ export declare function buildBootExtraContext(opts: BootExtraContextOptions): Record<string, unknown>;
37
38
  /** Die Auth-Felder die resolveAuthMail liest/normalisiert — beide
38
39
  * App-Auth-Typen (prod + dev) erfüllen das strukturell. */
39
40
  type AuthMailNormalizable = {
@@ -12,6 +12,7 @@ import { createTemplateResolverApi } from "@cosmicdrift/kumiko-bundled-features/
12
12
  import { TENANT_FEATURE, TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
13
13
  import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
14
14
  import { createSystemUser, } from "@cosmicdrift/kumiko-framework/engine";
15
+ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
15
16
  import { resolveBootCrypto } from "./boot/boot-crypto.js";
16
17
  // Boot-time context helpers for runProdApp: ctx-extra-context wiring
17
18
  // (templateResolver/delivery/secrets/config-resolver), auth-mail convenience
@@ -49,6 +50,11 @@ export function addConfigAccessorFactory(resolved, registry) {
49
50
  // inline, with `secrets` for chat-channel credentials. `to: { tenant }` resolves
50
51
  // through tenantUserIdsQuery, which the caller sets when the tenant feature is mounted.
51
52
  function buildDeliveryNotifyFactory(opts) {
53
+ if (!opts.addressCipher && opts.deliverQueuedInline !== true) {
54
+ createFallbackLogger("delivery", opts.log).warn("No master key configured: queued delivery jobs carry recipient addresses in plaintext " +
55
+ "(Redis, job runs). Set KUMIKO_SECRETS_MASTER_KEY_V1 (and KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION) " +
56
+ "on the web and worker processes to seal them.");
57
+ }
52
58
  const configAccessorFactory = opts.configResolver && createConfigAccessorFactory(opts.registry, opts.configResolver);
53
59
  const deliveryService = createDeliveryService({
54
60
  db: opts.db,
@@ -56,6 +62,7 @@ function buildDeliveryNotifyFactory(opts) {
56
62
  channels: collectChannels(opts.registry),
57
63
  ...(opts.redis && { idempotencyRedis: opts.redis }),
58
64
  ...(opts.secrets && { secrets: opts.secrets }),
65
+ ...(opts.addressCipher && { addressCipher: opts.addressCipher }),
59
66
  ...(opts.sseBroker && { sseBroker: opts.sseBroker }),
60
67
  ...(opts.escapeHatchAuditSink && { escapeHatchAuditSink: opts.escapeHatchAuditSink }),
61
68
  ...(opts.log && { log: opts.log }),
@@ -124,10 +131,31 @@ function resolveDeliveryConfigResolver(opts, hasDeliveryFeature, crypto) {
124
131
  ? createBootConfigResolver(opts.registry, opts.envSource, crypto)
125
132
  : undefined;
126
133
  }
134
+ function bootDeliveryContext(opts, parts) {
135
+ if (!opts.features.some((f) => f.name === DELIVERY_FEATURE))
136
+ return {};
137
+ const hasTenantFeature = opts.features.some((f) => f.name === TENANT_FEATURE);
138
+ const { crypto, secrets, escapeHatchAuditSink } = parts;
139
+ return {
140
+ _notifyFactory: buildDeliveryNotifyFactory({
141
+ db: opts.db,
142
+ registry: opts.registry,
143
+ ...(secrets && { secrets }),
144
+ ...(crypto.configCipher && { addressCipher: crypto.configCipher }),
145
+ ...(opts.sseBroker && { sseBroker: opts.sseBroker }),
146
+ ...(opts.redis && { redis: opts.redis }),
147
+ ...(escapeHatchAuditSink && { escapeHatchAuditSink }),
148
+ ...(opts.log && { log: opts.log }),
149
+ ...(hasTenantFeature && { tenantUserIdsQuery: TenantQueries.resolveUserIds }),
150
+ configResolver: parts.configResolver,
151
+ deliverQueuedInline: opts.deliverQueuedInline,
152
+ unsubscribeLinks: opts.unsubscribeLinks,
153
+ }),
154
+ };
155
+ }
127
156
  export function buildBootExtraContext(opts) {
128
157
  const crypto = opts.crypto ?? resolveBootCrypto(opts.envSource, opts.masterKey);
129
158
  const hasDeliveryFeature = opts.features.some((f) => f.name === DELIVERY_FEATURE);
130
- const hasTenantFeature = opts.features.some((f) => f.name === TENANT_FEATURE);
131
159
  const escapeHatchAuditSink = resolveEscapeHatchAuditSink(opts.features, opts.db);
132
160
  const secrets = resolveBootSecrets(opts.db, opts.features, crypto, opts.registry);
133
161
  const bootConfigResolver = resolveDeliveryConfigResolver(opts, hasDeliveryFeature, crypto);
@@ -136,20 +164,11 @@ export function buildBootExtraContext(opts) {
136
164
  templateResolver: createTemplateResolverApi(opts.db),
137
165
  ...(opts.kms && { kms: opts.kms }),
138
166
  ...(escapeHatchAuditSink && { _escapeHatchAuditSink: escapeHatchAuditSink }),
139
- ...(hasDeliveryFeature && {
140
- _notifyFactory: buildDeliveryNotifyFactory({
141
- db: opts.db,
142
- registry: opts.registry,
143
- ...(secrets && { secrets }),
144
- ...(opts.sseBroker && { sseBroker: opts.sseBroker }),
145
- ...(opts.redis && { redis: opts.redis }),
146
- ...(escapeHatchAuditSink && { escapeHatchAuditSink }),
147
- ...(opts.log && { log: opts.log }),
148
- ...(hasTenantFeature && { tenantUserIdsQuery: TenantQueries.resolveUserIds }),
149
- configResolver: bootConfigResolver,
150
- deliverQueuedInline: opts.deliverQueuedInline,
151
- unsubscribeLinks: opts.unsubscribeLinks,
152
- }),
167
+ ...bootDeliveryContext(opts, {
168
+ crypto,
169
+ secrets,
170
+ escapeHatchAuditSink,
171
+ configResolver: bootConfigResolver,
153
172
  }),
154
173
  // Top-level provider so feature jobs (secrets rotate, config reencrypt)
155
174
  // reach it via ctx — previously only test-stack wired it.
@@ -1,5 +1,6 @@
1
1
  import { join, resolve, sep } from "node:path";
2
2
  import { cachedResponse, computeStrongEtag, computeWeakEtag, createClientIpResolver, } from "@cosmicdrift/kumiko-framework/api";
3
+ import { rankPreferredLocales } from "@cosmicdrift/kumiko-framework/i18n";
3
4
  import { resolveAndInjectPageHead } from "@cosmicdrift/kumiko-headless/apex";
4
5
  import { errorCodeOf } from "@cosmicdrift/kumiko-types/type-guards";
5
6
  import { ASSETS_DIR } from "./build-prod-bundle.js";
@@ -194,6 +195,10 @@ trustedProxyHops = 0) {
194
195
  const injected = await resolveAndInjectPageHead(text, pageHead.resolvePageHead, {
195
196
  path: url.pathname,
196
197
  host,
198
+ preferredLocales: rankPreferredLocales({
199
+ queryLang: url.searchParams.get("lang"),
200
+ acceptLanguage: req.headers.get("accept-language"),
201
+ }),
197
202
  systemQuery,
198
203
  requestSignal: req.signal,
199
204
  });
@@ -249,7 +254,7 @@ trustedProxyHops = 0) {
249
254
  }
250
255
  // Per-host body (hostDispatch picks the file by host) → Vary: Host,
251
256
  // otherwise a shared cache could serve Host A's HTML to Host B.
252
- const extraHeaders = { vary: "Host" };
257
+ const extraHeaders = { vary: pageHead ? PAGE_HEAD_VARY : "Host" };
253
258
  if (csp)
254
259
  extraHeaders["content-security-policy"] = csp;
255
260
  const withHead = await applyPageHead(req, html, socketAddress);
@@ -304,13 +309,16 @@ trustedProxyHops = 0) {
304
309
  const index = await readHtmlFile(indexHtml);
305
310
  if (index) {
306
311
  const withHead = await applyPageHead(req, index, socketAddress);
307
- return serveHtmlFile(req, "/index.html", withHead);
312
+ return serveHtmlFile(req, "/index.html", withHead, pageHead && { vary: PAGE_HEAD_VARY });
308
313
  }
309
314
  // Kein Hono-Match, keine Disk-Datei, kein index.html → liefer den
310
315
  // ursprünglichen 404 von Hono durch (statt einen neuen Roundtrip).
311
316
  return honoRes;
312
317
  };
313
318
  }
319
+ // The resolved page head depends on host and preferred locale; ?lang= is part of the URL and
320
+ // already keys the cache.
321
+ const PAGE_HEAD_VARY = "Host, Accept-Language";
314
322
  // Map URL-Pfad → Cache-Policy. Hashed-Asset-Pfade (/assets/*) sind
315
323
  // unveränderlich, der Rest bleibt revalidate/no-cache damit Updates ohne
316
324
  // Hard-Reload greifen. Exported für Unit-Tests; Konsumenten gehen via
@@ -509,8 +509,7 @@ export type RunProdAppOptions = {
509
509
  * kein `/metrics`-Endpoint). Setze `createPrometheusMeter()`-basierten
510
510
  * Provider + `metrics` um `/metrics` real zu exposen (publicstatus#91). */
511
511
  readonly observability?: ObservabilityProvider;
512
- /** Konfiguriert die Auto-Instrumentation des resolvierten Observability-
513
- * Providers (siehe `BaseEntrypointOptions.observabilityOptions`). */
512
+ /** @deprecated No effect: the HTTP route label carries no query string anymore. */
514
513
  readonly observabilityOptions?: ObservabilityOptions;
515
514
  /** Aktiviert den `/metrics`-Endpoint (Open-Metrics-Format). Ohne einen
516
515
  * PrometheusMeter-basierten `observability`-Provider antwortet `/metrics`
@@ -60,7 +60,7 @@ import { assertSeedModeNotInProduction } from "@cosmicdrift/kumiko-framework/eve
60
60
  import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
61
61
  import { assertKumikoSchemaCurrent, SchemaDriftError, } from "@cosmicdrift/kumiko-framework/migrations";
62
62
  import { createMetricsForFactory, } from "@cosmicdrift/kumiko-framework/observability";
63
- import { createDispatcher, createEntityCache, createEventDedup, createIdempotencyGuard, flushEscapeHatchAudits, } from "@cosmicdrift/kumiko-framework/pipeline";
63
+ import { createDispatcher, createEntityCache, createEventDedup, createIdempotencyGuard, createUnlimitedRateLimitResolver, flushEscapeHatchAudits, } from "@cosmicdrift/kumiko-framework/pipeline";
64
64
  import { createRedisCacheSyncBus, redisClientOptionsFromEnv, } from "@cosmicdrift/kumiko-framework/redis";
65
65
  import { warnIfNonUtcServerTimeZone } from "@cosmicdrift/kumiko-framework/time";
66
66
  import { Redis } from "ioredis";
@@ -76,7 +76,7 @@ import { resolveBootOptionWiring } from "./boot-options/resolve-boot-options.js"
76
76
  import { assertStackBootRequirements } from "./boot-options/stack-boot-requirements.js";
77
77
  import { buildBunServeOptions, resolveDerivedMaxRequestBodySize } from "./bun-serve-options.js";
78
78
  import { buildComposeAuthOptions, composeFeatures } from "./compose-features.js";
79
- import { makeDispatchSystemWrite } from "./extra-routes-deps.js";
79
+ import { makeDispatchSystemQuery, makeDispatchSystemWrite, } from "./extra-routes-deps.js";
80
80
  import { assertPiiBootInvariants } from "./pii-boot-gate.js";
81
81
  import { resolveRateLimit } from "./rate-limit-defaults.js";
82
82
  import { resolveAuthPublicOrigin } from "./resolve-auth-public-origin.js";
@@ -614,7 +614,6 @@ export async function runProdApp(options) {
614
614
  },
615
615
  eventDedup,
616
616
  ...(options.observability && { observability: options.observability }),
617
- ...(options.observabilityOptions && { observabilityOptions: options.observabilityOptions }),
618
617
  ...(options.metrics && { metrics: options.metrics }),
619
618
  ...(resolvedRateLimit && { rateLimit: resolvedRateLimit }),
620
619
  ...(trustedProxyHops !== undefined && { trustedProxyHops }),
@@ -779,6 +778,7 @@ export async function runProdApp(options) {
779
778
  registry,
780
779
  metricsFor: createMetricsForFactory(entrypoint.observability.meter),
781
780
  dispatchSystemWrite: makeDispatchSystemWrite(entrypoint.dispatcher),
781
+ dispatchSystemQuery: makeDispatchSystemQuery(entrypoint.dispatcher),
782
782
  });
783
783
  }
784
784
  // 9. Seeds: admin first, then config-seeds from r.config({seeds}),
@@ -799,8 +799,17 @@ export async function runProdApp(options) {
799
799
  });
800
800
  const customSeeds = bootOptionWiring.customSeeds;
801
801
  const hasSeeds = customSeeds.length > 0 || options.seedsDir !== undefined;
802
+ // Seeds are bootstrap writes, not tenant traffic: they always get the unlimited resolver,
803
+ // even when the app has a real one, so a handler's rateLimit can neither fail nor drain them.
802
804
  const seedContext = hasSeeds
803
- ? withFileProviderResolver(registry, { db, redis, entityCache, registry, ...extraContext }, cacheSync)
805
+ ? withFileProviderResolver(registry, {
806
+ db,
807
+ redis,
808
+ entityCache,
809
+ registry,
810
+ ...extraContext,
811
+ rateLimit: createUnlimitedRateLimitResolver(),
812
+ }, cacheSync)
804
813
  : undefined;
805
814
  const seedDispatcher = seedContext
806
815
  ? withSeedErrorContext(createDispatcher(registry, seedContext, {
@@ -89,6 +89,7 @@ export type RunWorkerAppOptions = {
89
89
  * cannot derive them; without this it reads the env as is. */
90
90
  readonly kmsSlots?: readonly string[];
91
91
  readonly observability?: ObservabilityProvider;
92
+ /** @deprecated No effect: the HTTP route label carries no query string anymore. */
92
93
  readonly observabilityOptions?: ObservabilityOptions;
93
94
  /** Serves `/metrics` on its own port (the worker has no HTTP app). Needs a
94
95
  * PrometheusMeter-based `observability`; both are validated at boot,
@@ -20,7 +20,7 @@ import { startPiiEventBackfillOnBoot } from "./boot/pii-event-backfill-on-boot.j
20
20
  import { recordAppStartedOnBoot } from "./boot/record-app-started-on-boot.js";
21
21
  import { bootWorkerProcess, resolveWorkerEnvSource } from "./boot/worker-boot-core.js";
22
22
  import { startWorkerMetricsServer, } from "./boot/worker-metrics-server.js";
23
- import { makeDispatchSystemWrite } from "./extra-routes-deps.js";
23
+ import { makeDispatchSystemQuery, makeDispatchSystemWrite, } from "./extra-routes-deps.js";
24
24
  function makeBootModeHandle() {
25
25
  return {
26
26
  // @cast-boundary boot-mode: no entrypoint exists because no boot ran —
@@ -73,6 +73,7 @@ export async function runWorkerApp(options) {
73
73
  registry,
74
74
  metricsFor: createMetricsForFactory(entrypoint.observability.meter),
75
75
  dispatchSystemWrite: makeDispatchSystemWrite(entrypoint.dispatcher),
76
+ dispatchSystemQuery: makeDispatchSystemQuery(entrypoint.dispatcher),
76
77
  lifecycle: entrypoint.lifecycle,
77
78
  ...(boot.secrets && { secrets: boot.secrets }),
78
79
  });
@@ -1,15 +1,13 @@
1
1
  import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { createSystemUser, } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { reraiseAsKumikoError } from "@cosmicdrift/kumiko-framework/errors";
4
- import { createFileContext, createFileRefFromBytes, } from "@cosmicdrift/kumiko-framework/files";
4
+ import { createFileRefFromBytes, createTenantFileContext, } from "@cosmicdrift/kumiko-framework/files";
5
5
  export function makeSeedCreateFileRef(deps) {
6
6
  const { db, registry, context } = deps;
7
7
  const fileResolver = context._fileProviderResolver;
8
8
  return (tenantId, input) => createFileRefFromBytes({
9
9
  db: createTenantDb({ db, tenantId }),
10
- files: fileResolver
11
- ? createFileContext(() => fileResolver(tenantId), tenantId)
12
- : context.files,
10
+ files: fileResolver ? createTenantFileContext(fileResolver, tenantId) : context.files,
13
11
  registry,
14
12
  user: createSystemUser(tenantId),
15
13
  }, input);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-server-runtime",
3
- "version": "0.371.0",
3
+ "version": "0.372.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 tooling.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -88,19 +88,19 @@
88
88
  }
89
89
  },
90
90
  "dependencies": {
91
- "@cosmicdrift/kumiko-bundled-features": "0.371.0",
92
- "@cosmicdrift/kumiko-framework": "0.371.0",
93
- "@cosmicdrift/kumiko-headless": "0.371.0",
94
- "@cosmicdrift/kumiko-renderer-mail-html": "0.371.0",
95
- "@cosmicdrift/kumiko-renderer-web": "0.371.0",
96
- "@cosmicdrift/kumiko-types": "0.371.0",
91
+ "@cosmicdrift/kumiko-bundled-features": "0.372.0",
92
+ "@cosmicdrift/kumiko-framework": "0.372.0",
93
+ "@cosmicdrift/kumiko-headless": "0.372.0",
94
+ "@cosmicdrift/kumiko-renderer-mail-html": "0.372.0",
95
+ "@cosmicdrift/kumiko-renderer-web": "0.372.0",
96
+ "@cosmicdrift/kumiko-types": "0.372.0",
97
97
  "bun-types": "^1.3.13",
98
98
  "ioredis": "^5.10.1",
99
99
  "temporal-polyfill": "^0.3.2",
100
100
  "zod": "^4.4.3"
101
101
  },
102
102
  "devDependencies": {
103
- "@cosmicdrift/kumiko-testing": "0.371.0"
103
+ "@cosmicdrift/kumiko-testing": "0.372.0"
104
104
  },
105
105
  "publishConfig": {
106
106
  "registry": "https://registry.npmjs.org",
package/src/changes.json CHANGED
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.372.0",
4
+ "type": "fix",
5
+ "title": "Seeds no longer fail on write handlers that declare a rateLimit",
6
+ "detail": "The seed dispatcher gets an unlimited RateLimitResolver instead of none; requests keep the app's resolver and limits."
7
+ },
2
8
  {
3
9
  "version": "0.371.0",
4
10
  "type": "improvement",