@cosmicdrift/kumiko-server-runtime 0.362.0 → 0.363.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.
@@ -8,4 +8,11 @@ import { seedAllConfigValues } from "@cosmicdrift/kumiko-bundled-features/config
8
8
  // @wrapper-known semantic-alias
9
9
  export async function applyBootSeeds(deps) {
10
10
  await seedAllConfigValues(deps.registry, deps.db, deps.cipher);
11
+ // Loaded lazily: the seeding module pulls the document sanitizer (happy-dom),
12
+ // which apps without notification templates must not pay for at boot.
13
+ const hasNotificationTemplates = Array.from(deps.registry.getAllNotifications().values()).some((notification) => notification.template !== undefined);
14
+ if (hasNotificationTemplates) {
15
+ const { seedNotificationTemplates } = await import("@cosmicdrift/kumiko-bundled-features/template-resolver/seeding");
16
+ await seedNotificationTemplates(deps.db, deps.registry);
17
+ }
11
18
  }
@@ -6,12 +6,13 @@
6
6
  import { loadJwtSecretOrKeyring } from "@cosmicdrift/kumiko-framework/api";
7
7
  import { configureBlindIndexKey, configurePiiSubjectKms, resolvePlatformKeks, } from "@cosmicdrift/kumiko-framework/crypto";
8
8
  import { configureEntityFieldEncryption, createDbConnection, dbConnectionOptionsFromEnv, outsideTransactionPoolOptionsFromEnv, } from "@cosmicdrift/kumiko-framework/db";
9
- import { createRegistry, findTierResolverUsage, validateBoot, } from "@cosmicdrift/kumiko-framework/engine";
9
+ import { createRegistry, findTierResolverUsage, isTierResolverPlugin, validateBoot, } from "@cosmicdrift/kumiko-framework/engine";
10
10
  import { createWorkerEntrypoint, } from "@cosmicdrift/kumiko-framework/entrypoint";
11
11
  import { assertKumikoSchemaCurrent, SchemaDriftError, } from "@cosmicdrift/kumiko-framework/migrations";
12
12
  import { createEntityCache, createEventDedup, createIdempotencyGuard, } from "@cosmicdrift/kumiko-framework/pipeline";
13
13
  import { createRedisCacheSyncBus, redisClientOptionsFromEnv, } from "@cosmicdrift/kumiko-framework/redis";
14
14
  import { warnIfNonUtcServerTimeZone } from "@cosmicdrift/kumiko-framework/time";
15
+ import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
15
16
  import { Redis } from "ioredis";
16
17
  import { composeFeatures } from "../compose-features.js";
17
18
  import { assertPiiBootInvariants } from "../pii-boot-gate.js";
@@ -23,7 +24,7 @@ import { assertWorkerMetricsOptions } from "./worker-metrics-server.js";
23
24
  function isSecretsContext(value) {
24
25
  if (typeof value !== "object" || value === null)
25
26
  return false;
26
- return ["get", "has", "set", "delete"].every((method) => typeof value[method] === "function");
27
+ return ["get", "has", "set", "delete"].every((method) => typeof propertyOf(value, method) === "function");
27
28
  }
28
29
  export async function resolveWorkerEnvSource(options, processName) {
29
30
  const rawEnvSource = options.envSource ?? process.env;
@@ -39,6 +40,9 @@ async function buildTierEffectiveFeatures(features, db, registry, cacheSync) {
39
40
  if (!tierResolverUsage)
40
41
  return undefined;
41
42
  const plugin = tierResolverUsage.options;
43
+ if (!isTierResolverPlugin(plugin)) {
44
+ throw new Error("tier-resolver registration options must provide a build() factory");
45
+ }
42
46
  return plugin.build({ db, registry, cacheSync });
43
47
  }
44
48
  async function assertWorkerSchemaCurrent(db, migrationsDir, processName) {
@@ -60,7 +60,7 @@ import { canResolveTailwindStylesheet, resolveTailwindCli } from "./resolve-tail
60
60
  // (Tailwind via Bun.spawn, Client-Bundle via Bun.build, Stylesheet-
61
61
  // Resolution via Bun.resolveSync) sind Bun-only. Pro-Funktions-Inline-
62
62
  // Checks driften sonst — eine Konstante hier hält das konsistent.
63
- const hasBun = typeof globalThis.Bun !== "undefined";
63
+ const hasBun = typeof Bun !== "undefined";
64
64
  // Default-HTML wird nur genutzt wenn der App-Author KEIN index.html liefert.
65
65
  // Hat keine Asset-Placeholder, weil der Default-Pfad für vanilla apps
66
66
  // (nur public/) gedacht ist — wer JS/CSS will, schreibt ein eigenes
@@ -444,9 +444,8 @@ function resolveStylesheetEntry(cwd, clientEntry, override) {
444
444
  if (!hasBun)
445
445
  return undefined;
446
446
  try {
447
- const resolved = globalThis.Bun.resolveSync("@cosmicdrift/kumiko-renderer-web/styles.css", cwd);
448
- const bun = globalThis.Bun;
449
- if (!canResolveTailwindStylesheet(resolved, { bun, cwd })) {
447
+ const resolved = Bun.resolveSync("@cosmicdrift/kumiko-renderer-web/styles.css", cwd);
448
+ if (!canResolveTailwindStylesheet(resolved, { bun: Bun, cwd })) {
450
449
  return undefined;
451
450
  }
452
451
  return { path: resolved, isRendererWebFallback: true };
@@ -521,16 +520,14 @@ export async function runTailwindOnce(entry, cwd) {
521
520
  if (!hasBun) {
522
521
  throw new Error("[kumiko build] Tailwind one-shot requires Bun (Bun.spawn) — run via `bun run …` or `bun kumiko build`.");
523
522
  }
524
- const bunResolver = globalThis
525
- .Bun;
526
- const cliPath = resolveTailwindCli({ bun: bunResolver, cwd });
523
+ const cliPath = resolveTailwindCli({ bun: Bun, cwd });
527
524
  if (cliPath === undefined) {
528
525
  throw new Error("[kumiko build] @tailwindcss/cli nicht auflösbar — `bun install` im App-Root ausführen.");
529
526
  }
530
- if (!canResolveTailwindStylesheet(entry, { bun: bunResolver, cwd })) {
527
+ if (!canResolveTailwindStylesheet(entry, { bun: Bun, cwd })) {
531
528
  throw new Error(`[kumiko build] tailwindcss nicht auflösbar für ${entry} — peer dependency fehlt am Stylesheet-Ort.`);
532
529
  }
533
- const { compile, optimize, Scanner } = await loadTailwindEngine(cliPath, bunResolver);
530
+ const { compile, optimize, Scanner } = await loadTailwindEngine(cliPath, Bun);
534
531
  const input = resolve(cwd, entry);
535
532
  if (!existsSync(input)) {
536
533
  throw new Error(`[kumiko build] tailwind: stylesheet not found: ${input}`);
@@ -16,10 +16,13 @@ import { AUTH_SELF_REGISTRATION_FEATURE, createAuthEmailPasswordFeature, createA
16
16
  import { AUTH_MFA_FEATURE, mfaStatusCheckerFromFeature, } from "@cosmicdrift/kumiko-bundled-features/auth-mfa";
17
17
  import { createConfigFeature } from "@cosmicdrift/kumiko-bundled-features/config";
18
18
  import { createFilesTenantDataFeature } from "@cosmicdrift/kumiko-bundled-features/files-tenant-data";
19
+ import { createRendererFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/renderer-foundation";
20
+ import { createTemplateResolverFeature } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
19
21
  import { collectAssignableAppRoles, createTenantFeature, } from "@cosmicdrift/kumiko-bundled-features/tenant";
20
22
  import { TENANT_LIFECYCLE_FEATURE } from "@cosmicdrift/kumiko-bundled-features/tenant-lifecycle";
21
23
  import { createUserFeature } from "@cosmicdrift/kumiko-bundled-features/user";
22
24
  import { dedupeFeatures } from "@cosmicdrift/kumiko-framework/engine";
25
+ import { createRendererMailHtmlFeature } from "@cosmicdrift/kumiko-renderer-mail-html";
23
26
  const FILES_FEATURE = "files";
24
27
  const FILES_TENANT_DATA_FEATURE = "files-tenant-data";
25
28
  export function composeFeatures(appFeatures, options) {
@@ -92,10 +95,22 @@ export function composeFeatures(appFeatures, options) {
92
95
  !names.has(FILES_TENANT_DATA_FEATURE);
93
96
  return [
94
97
  ...bundled,
98
+ ...missingAuthMailStack(names),
95
99
  ...filteredApp,
96
100
  ...(needsFilesTenantData ? [createFilesTenantDataFeature()] : []),
97
101
  ];
98
102
  }
103
+ // auth-email-password declares its mails as stored templates, and the boot seed
104
+ // fails without the template-resolver and a mail renderer. Mounting the missing
105
+ // ones here keeps every includeBundled app bootable (also scaffolds); features
106
+ // the app already mounts are skipped because createRegistry rejects duplicates.
107
+ function missingAuthMailStack(mountedNames) {
108
+ return [
109
+ createTemplateResolverFeature(),
110
+ createRendererFoundationFeature(),
111
+ createRendererMailHtmlFeature(),
112
+ ].filter((feature) => !mountedNames.has(feature.name));
113
+ }
99
114
  // The magic-link mail fields shared by all five flows. Conditional spreads omit
100
115
  // undefined keys (exactOptionalPropertyTypes) and avoid the property-write that
101
116
  // trips noPropertyAccessFromIndexSignature on the type-aliased option shapes.
@@ -8,17 +8,16 @@ export const RENDERER_WEB_FONTS_URL_PREFIX = "/assets/kumiko/fonts/";
8
8
  export const RENDERER_WEB_FONTS_DIST_DIR = "assets/kumiko/fonts";
9
9
  /** Allowlist for servable font file names; also rules out path segments. */
10
10
  export const RENDERER_WEB_FONT_FILE_PATTERN = /^[a-z0-9-]+\.woff2$/;
11
- const hasBun = typeof globalThis.Bun !== "undefined";
11
+ const hasBun = typeof Bun !== "undefined";
12
12
  /** Directory of the woff2 files next to renderer-web's styles.css, or
13
13
  * undefined when the package can't be resolved from any `searchFrom` dir
14
14
  * (or outside Bun, which does the package-exports resolution). */
15
15
  export function resolveRendererWebFontsDir(searchFrom) {
16
16
  if (!hasBun)
17
17
  return undefined;
18
- const bun = globalThis.Bun;
19
18
  for (const from of searchFrom) {
20
19
  try {
21
- const stylesheet = bun.resolveSync("@cosmicdrift/kumiko-renderer-web/styles.css", from);
20
+ const stylesheet = Bun.resolveSync("@cosmicdrift/kumiko-renderer-web/styles.css", from);
22
21
  return join(dirname(stylesheet), "fonts");
23
22
  }
24
23
  catch {
@@ -2,8 +2,8 @@ import { type EmailVerificationOptions, type PasswordResetOptions } from "@cosmi
2
2
  import { resolveSessionStore } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
3
3
  import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
4
4
  import type { KmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
5
- import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
6
- import type { ConfigResolver, FeatureDefinition, Registry } from "@cosmicdrift/kumiko-framework/engine";
5
+ import { type DbConnection } from "@cosmicdrift/kumiko-framework/db";
6
+ import { type ConfigResolver, type FeatureDefinition, type Registry } from "@cosmicdrift/kumiko-framework/engine";
7
7
  import type { Logger } from "@cosmicdrift/kumiko-framework/logging";
8
8
  import type { MasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
9
9
  import type { Redis } from "ioredis";
@@ -9,6 +9,9 @@ import { bindPatAutoRevokeOnPasswordChangeFromFeature, revokeAllPatTokensForUser
9
9
  import { createSecretsContext, SECRETS_FEATURE_NAME, } from "@cosmicdrift/kumiko-bundled-features/secrets";
10
10
  import { bindAutoRevokeFromFeature } from "@cosmicdrift/kumiko-bundled-features/sessions";
11
11
  import { createTemplateResolverApi } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
12
+ import { TENANT_FEATURE, TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
13
+ import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
14
+ import { createSystemUser, } from "@cosmicdrift/kumiko-framework/engine";
12
15
  import { resolveBootCrypto } from "./boot/boot-crypto.js";
13
16
  // Boot-time context helpers for runProdApp: ctx-extra-context wiring
14
17
  // (templateResolver/delivery/secrets/config-resolver), auth-mail convenience
@@ -43,8 +46,10 @@ export function addConfigAccessorFactory(resolved, registry) {
43
46
  // and every notification silently skips. sseBroker optional (email/push don't
44
47
  // need it, in-app SSE does). Queued channels go through the delivery jobs of
45
48
  // the calling context's job runner (3rd factory arg); without one they send
46
- // inline, with `secrets` for chat-channel credentials.
49
+ // inline, with `secrets` for chat-channel credentials. `to: { tenant }` resolves
50
+ // through tenantUserIdsQuery, which the caller sets when the tenant feature is mounted.
47
51
  function buildDeliveryNotifyFactory(opts) {
52
+ const configAccessorFactory = opts.configResolver && createConfigAccessorFactory(opts.registry, opts.configResolver);
48
53
  const deliveryService = createDeliveryService({
49
54
  db: opts.db,
50
55
  registry: opts.registry,
@@ -54,6 +59,17 @@ function buildDeliveryNotifyFactory(opts) {
54
59
  ...(opts.sseBroker && { sseBroker: opts.sseBroker }),
55
60
  ...(opts.escapeHatchAuditSink && { escapeHatchAuditSink: opts.escapeHatchAuditSink }),
56
61
  ...(opts.log && { log: opts.log }),
62
+ ...(opts.tenantUserIdsQuery && { tenantUserIdsQuery: opts.tenantUserIdsQuery }),
63
+ ...(configAccessorFactory && {
64
+ tenantConfigFor: (tenantId) => {
65
+ const systemUser = createSystemUser(tenantId);
66
+ return configAccessorFactory({
67
+ user: { id: systemUser.id, tenantId },
68
+ db: createTenantDb(opts.db, tenantId),
69
+ ...(opts.secrets && { secrets: opts.secrets }),
70
+ });
71
+ },
72
+ }),
57
73
  });
58
74
  return createDeliveryNotifyFactory(deliveryService, {
59
75
  deliverQueuedInline: opts.deliverQueuedInline === true,
@@ -93,11 +109,21 @@ function bootCryptoContext(crypto) {
93
109
  ...(crypto.configCipher && { configEncryption: crypto.configCipher }),
94
110
  };
95
111
  }
112
+ function createBootConfigResolver(registry, envSource, crypto) {
113
+ return createConfigResolver({
114
+ appOverrides: buildEnvConfigOverrides(registry, envSource),
115
+ ...(crypto.configCipher && { cipher: crypto.configCipher }),
116
+ });
117
+ }
96
118
  export function buildBootExtraContext(opts) {
97
119
  const crypto = opts.crypto ?? resolveBootCrypto(opts.envSource, opts.masterKey);
98
120
  const hasDeliveryFeature = opts.features.some((f) => f.name === DELIVERY_FEATURE);
121
+ const hasTenantFeature = opts.features.some((f) => f.name === TENANT_FEATURE);
99
122
  const escapeHatchAuditSink = resolveEscapeHatchAuditSink(opts.features, opts.db);
100
123
  const secrets = resolveBootSecrets(opts.db, opts.features, crypto, opts.registry);
124
+ const configResolver = opts.hasAuth
125
+ ? createBootConfigResolver(opts.registry, opts.envSource, crypto)
126
+ : undefined;
101
127
  return {
102
128
  templateResolver: createTemplateResolverApi(opts.db),
103
129
  ...(opts.kms && { kms: opts.kms }),
@@ -111,6 +137,8 @@ export function buildBootExtraContext(opts) {
111
137
  ...(opts.redis && { redis: opts.redis }),
112
138
  ...(escapeHatchAuditSink && { escapeHatchAuditSink }),
113
139
  ...(opts.log && { log: opts.log }),
140
+ ...(hasTenantFeature && { tenantUserIdsQuery: TenantQueries.resolveUserIds }),
141
+ configResolver,
114
142
  deliverQueuedInline: opts.deliverQueuedInline,
115
143
  }),
116
144
  }),
@@ -118,12 +146,7 @@ export function buildBootExtraContext(opts) {
118
146
  // reach it via ctx — previously only test-stack wired it.
119
147
  ...bootCryptoContext(crypto),
120
148
  ...(secrets && { secrets }),
121
- ...(opts.hasAuth && {
122
- configResolver: createConfigResolver({
123
- appOverrides: buildEnvConfigOverrides(opts.registry, opts.envSource),
124
- ...(crypto.configCipher && { cipher: crypto.configCipher }),
125
- }),
126
- }),
149
+ ...(configResolver && { configResolver }),
127
150
  };
128
151
  }
129
152
  function buildAuthPathAppUrl(baseUrl, path) {
@@ -1,6 +1,7 @@
1
1
  import { resolve, sep } from "node:path";
2
2
  import { cachedResponse, computeStrongEtag, computeWeakEtag, createClientIpResolver, } from "@cosmicdrift/kumiko-framework/api";
3
3
  import { resolveAndInjectPageHead } from "@cosmicdrift/kumiko-headless/apex";
4
+ import { errorCodeOf } from "@cosmicdrift/kumiko-types/type-guards";
4
5
  import { ASSETS_DIR } from "./build-prod-bundle.js";
5
6
  import { BUNDLED_ASSETS_DIST_DIR } from "./bundled-assets.js";
6
7
  import { buildRequestBoundSystemQuery, } from "./request-bound-system-query.js";
@@ -35,7 +36,7 @@ export async function readStaticFile(filePath) {
35
36
  return { bytes, mime: mimeTypeFor(filePath), mtimeMs: fileStat.mtimeMs };
36
37
  }
37
38
  catch (err) {
38
- const code = err.code;
39
+ const code = errorCodeOf(err);
39
40
  // ENOENT: no such path. EISDIR: readFile() on a directory (e.g. GET
40
41
  // /assets where "assets" is a subfolder copied verbatim from public/).
41
42
  // ENOTDIR: a path segment that isn't a directory is used as one (e.g.
@@ -121,7 +122,7 @@ trustedProxyHops = 0) {
121
122
  if (!file)
122
123
  return null;
123
124
  return {
124
- bytes: file.bytes.buffer.slice(file.bytes.byteOffset, file.bytes.byteOffset + file.bytes.byteLength),
125
+ bytes: new Uint8Array(file.bytes).buffer,
125
126
  mime: file.mime,
126
127
  etag: computeWeakEtag(file.mtimeMs, file.bytes.byteLength),
127
128
  mtimeMs: file.mtimeMs,
@@ -51,7 +51,7 @@ import { UserQueries } from "@cosmicdrift/kumiko-bundled-features/user";
51
51
  import { createDefaultSseBroker, createRedisLoginRateLimiter, loadJwtSecretOrKeyring, parseTrustedProxyHopsEnv, TRUSTED_PROXY_HOPS_ENV, } from "@cosmicdrift/kumiko-framework/api";
52
52
  import { configureBlindIndexKey, configurePiiSubjectKms, resolvePlatformKeks, } from "@cosmicdrift/kumiko-framework/crypto";
53
53
  import { configureEntityFieldEncryption, createDbConnection, dbConnectionOptionsFromEnv, outsideTransactionPoolOptionsFromEnv, } from "@cosmicdrift/kumiko-framework/db";
54
- import { collectWriteHandlerQns, createRegistry, findTierResolverUsage, validateAppCustomScreenWriteQns, validateBoot, } from "@cosmicdrift/kumiko-framework/engine";
54
+ import { collectWriteHandlerQns, createRegistry, findTierResolverUsage, isTierResolverPlugin, validateAppCustomScreenWriteQns, validateBoot, } from "@cosmicdrift/kumiko-framework/engine";
55
55
  import { createAllInOneEntrypoint, createApiEntrypoint, } from "@cosmicdrift/kumiko-framework/entrypoint";
56
56
  import { KumikoBootError, kmsSlotsOf, parseEnv, withOptionalEnvKeys, } from "@cosmicdrift/kumiko-framework/env";
57
57
  import { renderDryRun } from "@cosmicdrift/kumiko-framework/env/dry-run";
@@ -360,6 +360,9 @@ export async function runProdApp(options) {
360
360
  const tierResolverUsage = findTierResolverUsage(features);
361
361
  if (tierResolverUsage) {
362
362
  const plugin = tierResolverUsage.options;
363
+ if (!isTierResolverPlugin(plugin)) {
364
+ throw new Error("tier-resolver registration options must provide a build() factory");
365
+ }
363
366
  resolvedEffectiveFeatures = await plugin.build({ db, registry, cacheSync });
364
367
  }
365
368
  }
@@ -749,7 +752,7 @@ export async function runProdApp(options) {
749
752
  // Options-Shape (inkl. idleTimeout: 0 für SSE) liegt in der
750
753
  // exportierten buildBunServeOptions-Funktion — siehe ihren
751
754
  // Header für die Begründung.
752
- if (typeof globalThis.Bun === "undefined") {
755
+ if (typeof Bun === "undefined") {
753
756
  // Klare Fehlermeldung statt nackter ReferenceError. Trifft wenn
754
757
  // jemand listen() unter Node/vitest aufruft ohne autoListen:false
755
758
  // — hilft beim Debug, statt sich an "Bun is not defined" abzumühen.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-server-runtime",
3
- "version": "0.362.0",
3
+ "version": "0.363.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,11 +84,12 @@
84
84
  }
85
85
  },
86
86
  "dependencies": {
87
- "@cosmicdrift/kumiko-bundled-features": "0.362.0",
88
- "@cosmicdrift/kumiko-framework": "0.362.0",
89
- "@cosmicdrift/kumiko-headless": "0.362.0",
90
- "@cosmicdrift/kumiko-renderer-web": "0.362.0",
91
- "@cosmicdrift/kumiko-types": "0.362.0",
87
+ "@cosmicdrift/kumiko-bundled-features": "0.363.0",
88
+ "@cosmicdrift/kumiko-framework": "0.363.0",
89
+ "@cosmicdrift/kumiko-headless": "0.363.0",
90
+ "@cosmicdrift/kumiko-renderer-mail-html": "0.363.0",
91
+ "@cosmicdrift/kumiko-renderer-web": "0.363.0",
92
+ "@cosmicdrift/kumiko-types": "0.363.0",
92
93
  "bun-types": "^1.3.13",
93
94
  "ioredis": "^5.10.1",
94
95
  "temporal-polyfill": "^0.3.2"
package/src/changes.json CHANGED
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.363.0",
4
+ "type": "improvement",
5
+ "title": "composeFeatures adds template-resolver, renderer-foundation and renderer-mail-html for auth apps",
6
+ "detail": "With includeBundled, the auth mails are stored templates and the boot seed needs the template-resolver and a mail renderer. composeFeatures adds whichever of template-resolver, renderer-foundation and renderer-mail-html the app does not mount itself, so runDevApp, runProdApp and scaffolds keep booting. Apps without the template tables need a schema migration (`kumiko schema generate`)."
7
+ },
8
+ {
9
+ "version": "0.363.0",
10
+ "type": "improvement",
11
+ "title": "Immediate notify renders with the tenant config in runProdApp",
12
+ "detail": "The boot delivery notify factory passes tenantConfigFor, built from the boot config resolver, to the delivery service, so immediate notifications render the tenant name in the mail layout like the delivery.render job does."
13
+ },
2
14
  {
3
15
  "version": "0.357.0",
4
16
  "type": "fix",