@cosmicdrift/kumiko-samples 0.374.0 → 0.377.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.
Files changed (199) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +6 -4
  3. package/packages/bundled-features/src/account-security/changes.json +6 -0
  4. package/packages/bundled-features/src/account-security/constants.ts +1 -0
  5. package/packages/bundled-features/src/account-security/index.ts +1 -0
  6. package/packages/bundled-features/src/admin-shell/web/client-plugin.tsx +1 -1
  7. package/packages/bundled-features/src/audit/web/client-plugin.tsx +1 -1
  8. package/packages/bundled-features/src/auth-email-password/mail-notifications.ts +1 -1
  9. package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +1 -1
  10. package/packages/bundled-features/src/auth-email-password/web/auth-form-primitives.tsx +1 -1
  11. package/packages/bundled-features/src/auth-email-password/web/client-plugin.ts +3 -3
  12. package/packages/bundled-features/src/auth-email-password/web/default-topbar-actions.tsx +1 -1
  13. package/packages/bundled-features/src/auth-email-password/web/index.ts +1 -1
  14. package/packages/bundled-features/src/auth-email-password/web/session.tsx +1 -1
  15. package/packages/bundled-features/src/auth-email-password/web/tenant-menu-items.tsx +1 -1
  16. package/packages/bundled-features/src/auth-email-password/web/tenant-switcher.tsx +1 -1
  17. package/packages/bundled-features/src/auth-email-password/web/user-menu.tsx +1 -1
  18. package/packages/bundled-features/src/auth-foundation/changes.json +6 -0
  19. package/packages/bundled-features/src/auth-foundation/env.ts +27 -0
  20. package/packages/bundled-features/src/auth-foundation/index.ts +1 -0
  21. package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
  22. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +1 -1
  23. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +1 -1
  24. package/packages/bundled-features/src/billing-foundation/contact-card.ts +14 -0
  25. package/packages/bundled-features/src/billing-foundation/handlers/billing-plans.query.ts +1 -1
  26. package/packages/bundled-features/src/billing-foundation/index.ts +1 -0
  27. package/packages/bundled-features/src/billing-foundation/plan-catalog.ts +1 -0
  28. package/packages/bundled-features/src/billing-foundation/types.ts +12 -0
  29. package/packages/bundled-features/src/billing-foundation/validate-options.ts +16 -0
  30. package/packages/bundled-features/src/billing-foundation/web/billing-contact-plan.tsx +32 -0
  31. package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +5 -15
  32. package/packages/bundled-features/src/billing-foundation/web/cancel-contract-dialog.tsx +2 -2
  33. package/packages/bundled-features/src/billing-foundation/web/checkout-consent-dialog.tsx +1 -1
  34. package/packages/bundled-features/src/billing-foundation/web/client-plugin.tsx +1 -1
  35. package/packages/bundled-features/src/billing-foundation/web/localized-params.ts +14 -0
  36. package/packages/bundled-features/src/cap-overview/web/cap-cards-panel.tsx +3 -3
  37. package/packages/bundled-features/src/cap-overview/web/client-plugin.tsx +1 -1
  38. package/packages/bundled-features/src/channel-email/changes.json +6 -0
  39. package/packages/bundled-features/src/channel-email/email-channel.ts +2 -1
  40. package/packages/bundled-features/src/compliance-profiles/web/client-plugin.tsx +1 -1
  41. package/packages/bundled-features/src/config/feature.ts +2 -2
  42. package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
  43. package/packages/bundled-features/src/custom-fields/web/client-plugin.tsx +1 -1
  44. package/packages/bundled-features/src/delivery/unsubscribe-page.ts +1 -1
  45. package/packages/bundled-features/src/delivery/web/client-plugin.tsx +1 -1
  46. package/packages/bundled-features/src/delivery/web/delivery-status-cell.tsx +1 -1
  47. package/packages/bundled-features/src/feature-toggles/web/client-plugin.tsx +1 -1
  48. package/packages/bundled-features/src/file-provider-s3/changes.json +6 -0
  49. package/packages/bundled-features/src/file-provider-s3/env-named-feature.ts +45 -0
  50. package/packages/bundled-features/src/file-provider-s3/env-schema.ts +20 -13
  51. package/packages/bundled-features/src/file-provider-s3/index.ts +4 -0
  52. package/packages/bundled-features/src/folders/web/client-plugin.tsx +1 -1
  53. package/packages/bundled-features/src/folders/web/folder-section.tsx +3 -4
  54. package/packages/bundled-features/src/host-routing/README.md +7 -0
  55. package/packages/bundled-features/src/host-routing/changes.json +6 -0
  56. package/packages/bundled-features/src/host-routing/constants.ts +4 -0
  57. package/packages/bundled-features/src/host-routing/dispatch.ts +34 -26
  58. package/packages/bundled-features/src/host-routing/feature.ts +122 -41
  59. package/packages/bundled-features/src/host-routing/host.ts +28 -1
  60. package/packages/bundled-features/src/host-routing/index.ts +3 -0
  61. package/packages/bundled-features/src/host-routing/options.ts +45 -2
  62. package/packages/bundled-features/src/host-routing/redirects.ts +33 -0
  63. package/packages/bundled-features/src/host-routing/tenant-lookup.ts +65 -0
  64. package/packages/bundled-features/src/host-routing/validate-options.ts +48 -1
  65. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +7 -0
  66. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +8 -0
  67. package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +42 -0
  68. package/packages/bundled-features/src/inbound-mail-foundation/index.ts +4 -0
  69. package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +100 -0
  70. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +2 -2
  71. package/packages/bundled-features/src/jobs/web/client-plugin.tsx +1 -1
  72. package/packages/bundled-features/src/legal-pages/changes.json +6 -0
  73. package/packages/bundled-features/src/legal-pages/feature.ts +14 -1
  74. package/packages/bundled-features/src/legal-pages/layout.ts +6 -6
  75. package/packages/bundled-features/src/legal-pages/web/client-plugin.ts +1 -1
  76. package/packages/bundled-features/src/mail-foundation/feature.ts +21 -22
  77. package/packages/bundled-features/src/managed-pages/web/client-plugin.tsx +1 -1
  78. package/packages/bundled-features/src/notes-history/web/client-plugin.tsx +1 -1
  79. package/packages/bundled-features/src/page-render/branding.ts +1 -1
  80. package/packages/bundled-features/src/page-render/layout.ts +2 -2
  81. package/packages/bundled-features/src/page-render/markdown.ts +1 -1
  82. package/packages/bundled-features/src/personal-access-tokens/i18n.ts +1 -1
  83. package/packages/bundled-features/src/personal-access-tokens/screens.ts +1 -1
  84. package/packages/bundled-features/src/secrets/feature.ts +4 -4
  85. package/packages/bundled-features/src/seo/sitemap.ts +1 -1
  86. package/packages/bundled-features/src/shared/mail-template-body.ts +1 -1
  87. package/packages/bundled-features/src/shared/purge-files-reaching-subject.ts +127 -2
  88. package/packages/bundled-features/src/tags/web/client-plugin.tsx +1 -1
  89. package/packages/bundled-features/src/template-resolver/changes.json +18 -0
  90. package/packages/bundled-features/src/template-resolver/document-blocks.ts +34 -0
  91. package/packages/bundled-features/src/template-resolver/document-ops.ts +515 -0
  92. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +9 -0
  93. package/packages/bundled-features/src/template-resolver/feature.ts +4 -0
  94. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +1 -0
  95. package/packages/bundled-features/src/template-resolver/handlers/document-base.ts +86 -0
  96. package/packages/bundled-features/src/template-resolver/handlers/document-blocks.query.ts +48 -0
  97. package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +11 -3
  98. package/packages/bundled-features/src/template-resolver/handlers/patch-draft.write.ts +125 -0
  99. package/packages/bundled-features/src/template-resolver/handlers/preview.query.ts +2 -1
  100. package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +2 -1
  101. package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +22 -1
  102. package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +24 -9
  103. package/packages/bundled-features/src/template-resolver/{web/placeholder-text.ts → placeholder-text.ts} +1 -1
  104. package/packages/bundled-features/src/template-resolver/qualified-names.ts +2 -0
  105. package/packages/bundled-features/src/template-resolver/seeding.ts +3 -1
  106. package/packages/bundled-features/src/template-resolver/template-bundle.ts +6 -1
  107. package/packages/bundled-features/src/template-resolver/template-preview.ts +24 -8
  108. package/packages/bundled-features/src/template-resolver/template-validation.ts +1 -1
  109. package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +3 -3
  110. package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +1 -1
  111. package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +7 -33
  112. package/packages/bundled-features/src/template-resolver/web/i18n.ts +24 -0
  113. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +2 -2
  114. package/packages/bundled-features/src/tenant/web/client-plugin.tsx +1 -1
  115. package/packages/bundled-features/src/tenant/web/member-status-cell.tsx +1 -1
  116. package/packages/bundled-features/src/tier-engine/web/client-plugin.tsx +1 -1
  117. package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
  118. package/packages/bundled-features/src/user-data-rights/env.ts +15 -0
  119. package/packages/bundled-features/src/user-data-rights/feature.ts +4 -2
  120. package/packages/bundled-features/src/user-data-rights/index.ts +1 -0
  121. package/packages/bundled-features/src/user-data-rights/lib/mail-transport-resolver.ts +1 -1
  122. package/packages/bundled-features/src/user-data-rights/lib/storage-provider-resolver.ts +1 -1
  123. package/packages/bundled-features/src/user-data-rights/mail-notifications.ts +1 -1
  124. package/packages/bundled-features/src/user-data-rights/web/client-plugin.tsx +1 -1
  125. package/packages/bundled-features/src/user-data-rights/web/privacy-center-screen.tsx +2 -2
  126. package/packages/bundled-features/src/waitlist/changes.json +6 -0
  127. package/packages/bundled-features/src/waitlist/legacy-migrate.ts +51 -2
  128. package/samples/apps/admin-console/package.json +0 -3
  129. package/samples/apps/admin-console/src/app/client.tsx +1 -1
  130. package/samples/apps/admin-console/src/app/shell.tsx +1 -1
  131. package/samples/apps/config-demo/package.json +1 -3
  132. package/samples/apps/config-demo/src/app/client.tsx +1 -1
  133. package/samples/apps/config-demo/src/app/shell.tsx +2 -2
  134. package/samples/apps/config-demo/src/features/demo/web/index.ts +1 -1
  135. package/samples/apps/config-demo/tsconfig.json +0 -12
  136. package/samples/apps/marketing-demo/package.json +1 -3
  137. package/samples/apps/marketing-demo/src/app/client.tsx +1 -1
  138. package/samples/apps/marketing-demo/src/app/shell.tsx +1 -1
  139. package/samples/apps/marketing-demo/src/features/assets/web/index.ts +1 -1
  140. package/samples/apps/marketing-demo/src/features/helpdesk/web/index.ts +1 -1
  141. package/samples/apps/showcase/package.json +1 -3
  142. package/samples/apps/showcase/src/app/client.tsx +1 -1
  143. package/samples/apps/showcase/src/app/shell.tsx +1 -1
  144. package/samples/apps/showcase/src/features/demos/web/index.ts +1 -1
  145. package/samples/apps/showcase/src/features/demos/web/pages/sidebar-panel.tsx +1 -1
  146. package/samples/apps/showcase/src/features/demos/web/pages/toast.tsx +1 -1
  147. package/samples/apps/showcase/src/features/items/web/index.ts +1 -1
  148. package/samples/apps/styleguide/package.json +1 -3
  149. package/samples/apps/styleguide/src/app/client.tsx +1 -1
  150. package/samples/apps/styleguide/src/app/shell.tsx +2 -2
  151. package/samples/apps/styleguide/src/features/ai-settings/web.ts +1 -1
  152. package/samples/apps/styleguide/src/features/content/web.tsx +2 -2
  153. package/samples/apps/styleguide/src/features/demo/web.ts +1 -1
  154. package/samples/apps/styleguide/src/features/examples/web.ts +1 -1
  155. package/samples/apps/styleguide/src/features/gallery/web/Gallery.tsx +1 -1
  156. package/samples/apps/styleguide/src/features/gallery/web/index.ts +1 -1
  157. package/samples/apps/styleguide/src/features/widgets/demo-locale.ts +1 -1
  158. package/samples/apps/styleguide/src/features/widgets/lib/format.ts +1 -1
  159. package/samples/apps/styleguide/src/features/widgets/web/DashboardFilterEcho.tsx +1 -1
  160. package/samples/apps/styleguide/src/features/widgets/web/FormExamples.tsx +1 -1
  161. package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +1 -1
  162. package/samples/apps/styleguide/src/features/widgets/web/index.ts +1 -1
  163. package/samples/apps/styleguide/tsconfig.json +0 -12
  164. package/samples/apps/ui-walkthrough/package.json +1 -3
  165. package/samples/apps/ui-walkthrough/src/app/client.tsx +1 -1
  166. package/samples/apps/ui-walkthrough/src/app/shell.tsx +1 -1
  167. package/samples/apps/ui-walkthrough/src/features/billing/web/index.ts +1 -1
  168. package/samples/apps/ui-walkthrough/src/features/rental/web/index.ts +1 -1
  169. package/samples/apps/ui-walkthrough/src/features/tasks/web/index.ts +1 -1
  170. package/samples/apps/ui-walkthrough/src/features/vehicles/web/index.ts +1 -1
  171. package/samples/apps/use-all-bundled/bin/main.ts +3 -2
  172. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +12 -0
  173. package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +1 -1
  174. package/samples/apps/use-all-bundled/feature-manifest.json +1 -0
  175. package/samples/apps/use-all-bundled/package.json +1 -3
  176. package/samples/apps/use-all-bundled/src/app/client.tsx +1 -1
  177. package/samples/apps/use-all-bundled/src/app/dev-app-options.ts +5 -4
  178. package/samples/apps/use-all-bundled/src/app/shell.tsx +1 -1
  179. package/samples/apps/use-all-bundled/src/run-config.ts +10 -1
  180. package/samples/apps/workspaces/package.json +1 -3
  181. package/samples/apps/workspaces/src/app/client.tsx +1 -1
  182. package/samples/apps/workspaces/src/app/shell.tsx +1 -1
  183. package/samples/apps/workspaces/src/features/demo/web/index.ts +1 -1
  184. package/samples/apps/workspaces/src/features/demo-driver/web/index.ts +1 -1
  185. package/samples/recipes/apex-landing/package.json +1 -1
  186. package/samples/recipes/apex-landing/src/feature.ts +1 -1
  187. package/samples/recipes/apex-surface-auth/README.md +1 -1
  188. package/samples/recipes/apex-surface-auth/src/feature.ts +2 -2
  189. package/samples/recipes/compose-stacks/README.md +7 -4
  190. package/samples/recipes/embedded-entity-form/package.json +0 -1
  191. package/samples/recipes/embedded-entity-form/src/web/accept-suggestion-drawer.tsx +1 -1
  192. package/samples/recipes/notes-history-basic/README.md +1 -1
  193. package/samples/recipes/record-detail-layout/package.json +0 -1
  194. package/samples/recipes/tags-basic/README.md +1 -1
  195. package/samples/recipes/user-profile/README.md +1 -1
  196. package/samples/recipes/wizard-form/package.json +0 -1
  197. package/samples/recipes/wizard-form/src/web/index.ts +2 -2
  198. package/samples/recipes/wizard-form/src/web/listing-review-section.tsx +2 -2
  199. package/samples/recipes/writeform-section/package.json +0 -1
@@ -1,3 +1,4 @@
1
+ import type { DbConnection, TenantDb } from "@cosmicdrift/kumiko-framework/db";
1
2
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
2
3
 
3
4
  // What a host (or a tenant) gets served on the HTML fallback.
@@ -20,11 +21,43 @@ export type HostRoutingLocales = {
20
21
  readonly prefixed: readonly string[];
21
22
  };
22
23
 
24
+ // Maps a normalized hostname (lowercase, no port) that is neither the apex nor
25
+ // a fixed host to the tenant it belongs to, e.g. a subdomain or custom domain
26
+ // stored in an app table. Null means "no tenant here": the dispatch answers
27
+ // 404 and the API answers tenant_required, never a default tenant. The
28
+ // result still has to be an enabled, active tenant. Runs on every request
29
+ // that reaches such a host, so keep it to one indexed lookup.
30
+ export type HostTenantResolver = (
31
+ host: string,
32
+ db: DbConnection | TenantDb,
33
+ ) => Promise<TenantId | null> | TenantId | null;
34
+
23
35
  export type HostRoutingTenantMode =
24
36
  // <key>.<baseDomain> names the tenant.
25
- | { readonly tenantBy: "host" }
37
+ | {
38
+ readonly tenantBy: "host";
39
+ // Also serve the tenant surface on the apex under <tenantPathPrefix>/<key>/...
40
+ readonly tenantPathPrefix?: string;
41
+ }
26
42
  // <baseDomain><tenantPathPrefix>/<key>/... names the tenant.
27
- | { readonly tenantBy: "path"; readonly tenantPathPrefix: string };
43
+ | { readonly tenantBy: "path"; readonly tenantPathPrefix: string }
44
+ // The app resolves every other host itself (custom domains, profile subdomains).
45
+ | {
46
+ readonly tenantBy: HostTenantResolver;
47
+ readonly tenantPathPrefix?: string;
48
+ };
49
+
50
+ // Fixed, app-configured redirect. `to` is a same-origin path or an https URL;
51
+ // nothing from the request ever picks the target, only the matched remainder
52
+ // of the path is appended.
53
+ export type HostRoutingRedirect = {
54
+ // Path (exact or as a segment prefix, e.g. "/old" matches "/old" and "/old/x").
55
+ readonly from: string;
56
+ readonly to: string;
57
+ // Limits the rule to one lowercase hostname without port. Omit for every host.
58
+ readonly host?: string;
59
+ readonly status?: 301 | 302;
60
+ };
28
61
 
29
62
  export type HostRoutingOptions = HostRoutingTenantMode & {
30
63
  // Apex host without port, e.g. "example.com". "www.<baseDomain>" counts as apex.
@@ -40,4 +73,14 @@ export type HostRoutingOptions = HostRoutingTenantMode & {
40
73
  // Tenant for anonymous API calls on the apex (e.g. SYSTEM_TENANT_ID for
41
74
  // public signup forms). Omit and the apex answers tenant_required.
42
75
  readonly apexAnonymousTenantId?: TenantId;
76
+ // Per apex hostname ("example.com" or "www.example.com"): the only
77
+ // /api/query and /api/stream types that get the apexAnonymousTenantId anchor. Other anonymous
78
+ // queries on that host get no tenant (tenant_required). "example.com" and
79
+ // "www.example.com" share one entry: a host without its own uses the other's.
80
+ // Other paths (e.g. /api/batch) keep the anchor for everything.
81
+ // Not allowed with tenantBy "path" (boot error): there the client's X-Tenant
82
+ // wins over the resolver, so the allowlist would be ineffective.
83
+ readonly apexAnonymousQueryAllowlist?: Readonly<Record<string, readonly string[]>>;
84
+ // Checked first, before any host classification.
85
+ readonly redirects?: readonly HostRoutingRedirect[];
43
86
  };
@@ -0,0 +1,33 @@
1
+ import { normalizeHostname } from "./host.ts";
2
+ import type { HostRoutingRedirect } from "./options.ts";
3
+
4
+ // Same-origin targets collapse leading slashes and backslashes so an appended
5
+ // "//evil.example" remainder can never turn into a protocol-relative URL.
6
+ export function sameOriginPath(path: string): string {
7
+ return `/${path.replace(/^[/\\]+/, "")}`;
8
+ }
9
+
10
+ function remainderAfter(from: string, path: string): string | null {
11
+ if (path === from) return "";
12
+ const prefix = from.endsWith("/") ? from : `${from}/`;
13
+ return path.startsWith(prefix) ? path.slice(prefix.length - 1) : null;
14
+ }
15
+
16
+ export function matchRedirect(
17
+ redirects: readonly HostRoutingRedirect[],
18
+ host: string,
19
+ path: string,
20
+ search: string,
21
+ ): { readonly to: string; readonly status: 301 | 302 } | undefined {
22
+ const hostname = normalizeHostname(host);
23
+ for (const rule of redirects) {
24
+ if (rule.host !== undefined && rule.host !== hostname) continue;
25
+ const remainder = remainderAfter(rule.from, path);
26
+ if (remainder === null) continue;
27
+ const target = rule.to.startsWith("https://")
28
+ ? `${rule.to.replace(/\/$/, "")}${remainder}`
29
+ : sameOriginPath(`${rule.to.replace(/\/$/, "")}${remainder}`);
30
+ return { to: `${target}${search}`, status: rule.status ?? 301 };
31
+ }
32
+ return undefined;
33
+ }
@@ -0,0 +1,65 @@
1
+ import type { DbConnection, TenantDb } from "@cosmicdrift/kumiko-framework/db";
2
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
3
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
+ import {
5
+ isTenantServingPublicContent,
6
+ type TenantLifecycleStatus,
7
+ tenantTable,
8
+ } from "../tenant/index.ts";
9
+ import { classifyHost } from "./host.ts";
10
+ import type { HostRoutingOptions } from "./options.ts";
11
+
12
+ type TenantRow = {
13
+ readonly id: TenantId;
14
+ readonly isEnabled: boolean;
15
+ readonly status: TenantLifecycleStatus;
16
+ };
17
+
18
+ // Disabled tenants and tenants in any destroy phase are not routable, the same
19
+ // rule public files and pages follow.
20
+ export async function servingTenantIdByKey(
21
+ db: DbConnection | TenantDb,
22
+ key: string,
23
+ ): Promise<TenantId | null> {
24
+ const row = await fetchOne<TenantRow>(db, tenantTable, { key });
25
+ return row !== undefined && isTenantServingPublicContent(row) ? row.id : null;
26
+ }
27
+
28
+ export async function isTenantServing(db: DbConnection | TenantDb, id: TenantId): Promise<boolean> {
29
+ const row = await fetchOne<TenantRow>(db, tenantTable, { id });
30
+ return row !== undefined && isTenantServingPublicContent(row);
31
+ }
32
+
33
+ export async function servingTenantIdByResolver(
34
+ options: HostRoutingOptions,
35
+ db: DbConnection | TenantDb,
36
+ hostname: string,
37
+ ): Promise<TenantId | null> {
38
+ if (typeof options.tenantBy !== "function") return null;
39
+ const resolved = await options.tenantBy(hostname, db);
40
+ return resolved !== null && (await isTenantServing(db, resolved)) ? resolved : null;
41
+ }
42
+
43
+ // The tenant a Host header belongs to, without any request: subdomain key,
44
+ // resolver result or the apex anchor. Fixed and unknown hosts have none.
45
+ // Meant for features that serve per-tenant content by host, e.g. the
46
+ // `resolveApexTenant` option of managed-pages.
47
+ export function createHostTenantIdResolver(
48
+ options: HostRoutingOptions,
49
+ db: DbConnection | TenantDb,
50
+ ): (host: string) => Promise<TenantId | null> {
51
+ return async (host) => {
52
+ const classified = classifyHost(host, options);
53
+ switch (classified.kind) {
54
+ case "subdomain":
55
+ return options.tenantBy === "host" ? servingTenantIdByKey(db, classified.key) : null;
56
+ case "resolved":
57
+ return servingTenantIdByResolver(options, db, classified.hostname);
58
+ case "apex":
59
+ return options.apexAnonymousTenantId ?? null;
60
+ case "fixed":
61
+ case "unknown":
62
+ return null;
63
+ }
64
+ };
65
+ }
@@ -1,9 +1,51 @@
1
+ import { isApexHostname } from "./host.ts";
1
2
  import type { HostRoutingOptions } from "./options.ts";
2
3
 
3
4
  const DOMAIN_PATTERN = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/;
4
5
  const PATH_PREFIX_PATTERN = /^\/[a-z0-9-]+(?:\/[a-z0-9-]+)*$/;
5
6
  const LOCALE_PATTERN = /^[a-z]{2}(?:-[a-z]{2})?$/;
6
7
 
8
+ const REDIRECT_PATH_PATTERN = /^\/[A-Za-z0-9._~/-]*$/;
9
+ // A redirect target is either a same-origin path or an https URL; neither may
10
+ // carry a query or fragment, because the request's query string is appended.
11
+ const REDIRECT_TARGET_PATTERN =
12
+ /^(?:\/(?!\/)[A-Za-z0-9._~/-]*|https:\/\/[a-z0-9.-]+(?::[0-9]{1,5})?(?:\/[A-Za-z0-9._~/-]*)?)$/;
13
+
14
+ function validateRedirects(options: HostRoutingOptions): void {
15
+ for (const rule of options.redirects ?? []) {
16
+ if (!REDIRECT_PATH_PATTERN.test(rule.from)) {
17
+ throw new Error(`host-routing: redirect from must be a path like "/old", got "${rule.from}"`);
18
+ }
19
+ if (!REDIRECT_TARGET_PATTERN.test(rule.to)) {
20
+ throw new Error(
21
+ `host-routing: redirect to must be a same-origin path or an https URL without query, got "${rule.to}"`,
22
+ );
23
+ }
24
+ if (rule.host !== undefined && !DOMAIN_PATTERN.test(rule.host)) {
25
+ throw new Error(
26
+ `host-routing: redirect host must be a lowercase hostname, got "${rule.host}"`,
27
+ );
28
+ }
29
+ }
30
+ }
31
+
32
+ function validateAnonymousQueryAllowlist(options: HostRoutingOptions): void {
33
+ if (options.apexAnonymousQueryAllowlist !== undefined && options.tenantBy === "path") {
34
+ // Path mode resolves as fallback-only: a client-sent X-Tenant or cookie wins
35
+ // before the resolver runs, so the allowlist could never hold.
36
+ throw new Error(
37
+ 'host-routing: apexAnonymousQueryAllowlist cannot be used with tenantBy "path", because the client names its tenant there and bypasses the allowlist',
38
+ );
39
+ }
40
+ for (const host of Object.keys(options.apexAnonymousQueryAllowlist ?? {})) {
41
+ if (!isApexHostname(host, options.baseDomain)) {
42
+ throw new Error(
43
+ `host-routing: apexAnonymousQueryAllowlist key must be the apex or www.<apex> hostname, got "${host}"`,
44
+ );
45
+ }
46
+ }
47
+ }
48
+
7
49
  // Checked once when the app composes its features, so a config typo fails
8
50
  // the boot instead of routing every request to a 404.
9
51
  export function validateHostRoutingOptions(options: HostRoutingOptions): void {
@@ -17,11 +59,16 @@ export function validateHostRoutingOptions(options: HostRoutingOptions): void {
17
59
  throw new Error(`host-routing: hosts key must be a lowercase hostname, got "${host}"`);
18
60
  }
19
61
  }
20
- if (options.tenantBy === "path" && !PATH_PREFIX_PATTERN.test(options.tenantPathPrefix)) {
62
+ if (
63
+ options.tenantPathPrefix !== undefined &&
64
+ !PATH_PREFIX_PATTERN.test(options.tenantPathPrefix)
65
+ ) {
21
66
  throw new Error(
22
67
  `host-routing: tenantPathPrefix must look like "/app" (leading slash, no trailing slash), got "${options.tenantPathPrefix}"`,
23
68
  );
24
69
  }
70
+ validateRedirects(options);
71
+ validateAnonymousQueryAllowlist(options);
25
72
  const { locales } = options;
26
73
  if (locales !== undefined) {
27
74
  for (const locale of [locales.default, ...locales.prefixed]) {
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.377.0",
4
+ "type": "improvement",
5
+ "title": "Declarative mailbox list and rename screens",
6
+ "detail": "The feature registers the projectionList mail-accounts (name, address, provider, status, connected; row actions rename and disconnect) and the actionForm mail-account-edit, both restricted to SystemAdmin and TenantAdmin like the handlers. The list shows several mailboxes per tenant, never a credential. Screens are dormant, so the app places the list in its own nav. MAIL_ACCOUNT_LIST_SCREEN_ID and MAIL_ACCOUNT_EDIT_SCREEN_ID are exported.",
7
+ "migration": "Place mail-accounts in the app nav with r.nav; no other change."
8
+ },
2
9
  {
3
10
  "version": "0.370.0",
4
11
  "type": "breaking",
@@ -79,6 +79,7 @@ import { ingestMessageHandler } from "./handlers/ingest-message.write.ts";
79
79
  import { listAccountsQuery } from "./handlers/list-accounts.query.ts";
80
80
  import { listMessagesQuery } from "./handlers/list-messages.query.ts";
81
81
  import { updateAccountHandler } from "./handlers/update-account.write.ts";
82
+ import { INBOUND_MAIL_FOUNDATION_I18N } from "./i18n.ts";
82
83
  import {
83
84
  applyInboundMessageReceived,
84
85
  applyMailAccountConnected,
@@ -90,6 +91,7 @@ import {
90
91
  mailThreadsProjectionTable,
91
92
  } from "./projection.ts";
92
93
  import { runInboundMailRetention } from "./retention-sweep.ts";
94
+ import { mailAccountEditScreen, mailAccountListScreen } from "./screens.ts";
93
95
  import {
94
96
  INBOUND_MAIL_TENANT_DESTROY_ARCHIVE_REASON,
95
97
  inboundMessageTenantDestroyHook,
@@ -195,6 +197,12 @@ export function createInboundMailFoundationFeature() {
195
197
  r.queryHandler(listAccountsQuery);
196
198
  r.queryHandler(listMessagesQuery);
197
199
 
200
+ // Declarative mailbox admin: list (rename, disconnect) and rename form.
201
+ // The app places `mailAccountListScreen` via r.nav in its settings area.
202
+ r.screen(mailAccountListScreen);
203
+ r.screen(mailAccountEditScreen);
204
+ r.translations({ keys: INBOUND_MAIL_FOUNDATION_I18N });
205
+
198
206
  // Entity-genaue GDPR-Destroy-Hooks — der gdpr-storage-Boot-Validator
199
207
  // prueft die EXT_TENANT_DATA-Registrierung pro tenant-subject-Entity.
200
208
  r.useExtension(EXT_TENANT_DATA, "mail-account", {
@@ -0,0 +1,42 @@
1
+ import { ACTION_FORM_ENTITY } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import { fieldLabelKey } from "@cosmicdrift/kumiko-renderer/headless";
3
+ import { INBOUND_MAIL_FOUNDATION_FEATURE, InboundMailAccountStatuses } from "./constants.ts";
4
+ import {
5
+ MAIL_ACCOUNT_EDIT_SCREEN_ID,
6
+ MAIL_ACCOUNT_LIST_SCREEN_ID,
7
+ MAIL_ACCOUNT_STATUS_OPTION_KEY_PREFIX,
8
+ } from "./screens.ts";
9
+
10
+ const formFieldKey = (field: string): string =>
11
+ fieldLabelKey(INBOUND_MAIL_FOUNDATION_FEATURE, ACTION_FORM_ENTITY, field);
12
+
13
+ export const INBOUND_MAIL_FOUNDATION_I18N: Readonly<Record<string, { readonly en: string }>> = {
14
+ [`screen:${MAIL_ACCOUNT_LIST_SCREEN_ID}.title`]: { en: "Mailboxes" },
15
+ [`screen:${MAIL_ACCOUNT_EDIT_SCREEN_ID}.title`]: { en: "Edit mailbox" },
16
+ "inboundMail.accounts.col.name": { en: "Name" },
17
+ "inboundMail.accounts.col.address": { en: "Address" },
18
+ "inboundMail.accounts.col.provider": { en: "Provider" },
19
+ "inboundMail.accounts.col.status": { en: "Status" },
20
+ "inboundMail.accounts.col.connected": { en: "Connected" },
21
+ [`${MAIL_ACCOUNT_STATUS_OPTION_KEY_PREFIX}${InboundMailAccountStatuses.active}`]: {
22
+ en: "Active",
23
+ },
24
+ [`${MAIL_ACCOUNT_STATUS_OPTION_KEY_PREFIX}${InboundMailAccountStatuses.authError}`]: {
25
+ en: "Sign-in failed",
26
+ },
27
+ [`${MAIL_ACCOUNT_STATUS_OPTION_KEY_PREFIX}${InboundMailAccountStatuses.degraded}`]: {
28
+ en: "Interrupted",
29
+ },
30
+ [`${MAIL_ACCOUNT_STATUS_OPTION_KEY_PREFIX}${InboundMailAccountStatuses.disconnected}`]: {
31
+ en: "Disconnected",
32
+ },
33
+ "inboundMail.accounts.edit": { en: "Rename" },
34
+ "inboundMail.accounts.edit.submit": { en: "Save" },
35
+ "inboundMail.accounts.disconnect": { en: "Disconnect" },
36
+ "inboundMail.accounts.disconnect.confirm": {
37
+ en: "Disconnect this mailbox? No new mail is fetched; stored mail is kept.",
38
+ },
39
+ [formFieldKey("accountId")]: { en: "Mailbox" },
40
+ [formFieldKey("displayName")]: { en: "Name" },
41
+ [formFieldKey("reason")]: { en: "Reason" },
42
+ };
@@ -77,6 +77,10 @@ export {
77
77
  resolveInboundProviderForAccount,
78
78
  resolveInboundProviderForKey,
79
79
  } from "./provider-factory.ts";
80
+ export {
81
+ MAIL_ACCOUNT_EDIT_SCREEN_ID,
82
+ MAIL_ACCOUNT_LIST_SCREEN_ID,
83
+ } from "./screens.ts";
80
84
  export {
81
85
  InboundAuthError,
82
86
  InboundCursorInvalidError,
@@ -0,0 +1,100 @@
1
+ import {
2
+ i18nKey,
3
+ MAX_LIST_LIMIT,
4
+ type ProjectionListScreenDefinition,
5
+ } from "@cosmicdrift/kumiko-framework/engine";
6
+ import type { ActionFormScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
7
+ import {
8
+ InboundMailAccountStatuses,
9
+ InboundMailFoundationHandlers,
10
+ InboundMailFoundationQueries,
11
+ } from "./constants.ts";
12
+
13
+ export const MAIL_ACCOUNT_LIST_SCREEN_ID = "mail-accounts";
14
+ export const MAIL_ACCOUNT_EDIT_SCREEN_ID = "mail-account-edit";
15
+
16
+ export const MAIL_ACCOUNT_STATUS_OPTION_KEY_PREFIX = "inboundMail.accounts.status.";
17
+
18
+ // Same roles as the update/disconnect handlers: a screen rule wider than the
19
+ // handlers would only render actions that 403.
20
+ const MAIL_ACCOUNT_ADMIN_ACCESS = { roles: ["SystemAdmin", "TenantAdmin"] } as const;
21
+
22
+ // Several mailboxes per tenant, one row each. The bound query already filters
23
+ // personal mailboxes the caller neither owns nor administers and returns no
24
+ // credential: the secret lives in the secrets feature, never in the row.
25
+ // dormant: the consuming app places the list in its own settings nav.
26
+ export const mailAccountListScreen: ProjectionListScreenDefinition = {
27
+ id: MAIL_ACCOUNT_LIST_SCREEN_ID,
28
+ type: "projectionList",
29
+ agent: { expose: false },
30
+ query: InboundMailFoundationQueries.listAccounts,
31
+ pagination: false,
32
+ pageSize: MAX_LIST_LIMIT,
33
+ columns: [
34
+ { field: "displayName", label: i18nKey("inboundMail.accounts.col.name"), sortable: false },
35
+ { field: "address", label: i18nKey("inboundMail.accounts.col.address"), sortable: false },
36
+ {
37
+ field: "provider",
38
+ label: i18nKey("inboundMail.accounts.col.provider"),
39
+ sortable: false,
40
+ hideOnNarrow: true,
41
+ },
42
+ {
43
+ field: "status",
44
+ label: i18nKey("inboundMail.accounts.col.status"),
45
+ sortable: false,
46
+ renderer: { format: "enumOption", keyPrefix: MAIL_ACCOUNT_STATUS_OPTION_KEY_PREFIX },
47
+ },
48
+ {
49
+ field: "connectedAt",
50
+ label: i18nKey("inboundMail.accounts.col.connected"),
51
+ sortable: false,
52
+ hideOnNarrow: true,
53
+ renderer: { format: "timestamp" },
54
+ },
55
+ ],
56
+ rowActions: [
57
+ {
58
+ kind: "navigate",
59
+ id: "edit",
60
+ label: i18nKey("inboundMail.accounts.edit"),
61
+ screen: MAIL_ACCOUNT_EDIT_SCREEN_ID,
62
+ params: { map: { accountId: "id", displayName: "displayName" } },
63
+ visible: { field: "status", ne: InboundMailAccountStatuses.disconnected },
64
+ },
65
+ {
66
+ kind: "writeHandler",
67
+ id: "disconnect",
68
+ label: i18nKey("inboundMail.accounts.disconnect"),
69
+ handler: InboundMailFoundationHandlers.disconnectAccount,
70
+ payload: { map: { accountId: "id" } },
71
+ style: "danger",
72
+ confirm: i18nKey("inboundMail.accounts.disconnect.confirm"),
73
+ visible: { field: "status", ne: InboundMailAccountStatuses.disconnected },
74
+ },
75
+ ],
76
+ access: MAIL_ACCOUNT_ADMIN_ACCESS,
77
+ dormant: true,
78
+ };
79
+
80
+ // The reason is the audit label update-account requires; it is not in the
81
+ // layout, so the default is what gets written.
82
+ export const mailAccountEditScreen: ActionFormScreenDefinition = {
83
+ id: MAIL_ACCOUNT_EDIT_SCREEN_ID,
84
+ type: "actionForm",
85
+ agent: { expose: false },
86
+ handler: InboundMailFoundationHandlers.updateAccount,
87
+ fields: {
88
+ accountId: { type: "text", required: true },
89
+ displayName: { type: "text", required: true, maxLength: 200 },
90
+ reason: { type: "text", default: "tenant_admin" },
91
+ },
92
+ layout: {
93
+ sections: [{ fields: [{ field: "accountId", readOnly: true }, "displayName"] }],
94
+ },
95
+ submitLabel: i18nKey("inboundMail.accounts.edit.submit"),
96
+ redirect: MAIL_ACCOUNT_LIST_SCREEN_ID,
97
+ cancelTarget: MAIL_ACCOUNT_LIST_SCREEN_ID,
98
+ access: MAIL_ACCOUNT_ADMIN_ACCESS,
99
+ dormant: true,
100
+ };
@@ -21,7 +21,7 @@ import { INBOUND_MAIL_PROVIDER_EXTENSION, type InboundMailAccountStatus } from "
21
21
  * config — tenant config reads (host/port/... of the plugins)
22
22
  * registry — extension lookup in the factory (not plugin-internal)
23
23
  * secrets — tenant secret reads (IMAP password, OAuth refresh token)
24
- * _userId — audit identity for secret reads. Handler path: the dispatcher
24
+ * auditUserId — audit identity for secret reads. Handler path: the dispatcher
25
25
  * sets the caller user ID; worker path: system identity.
26
26
  * metricsFor — metrics handle of a feature by name, same behavior as
27
27
  * HandlerContext.metricsFor: unregistered metrics are a silent
@@ -31,7 +31,7 @@ export type InboundMailContext = {
31
31
  readonly config?: ConfigAccessor;
32
32
  readonly registry?: Registry;
33
33
  readonly secrets?: import("@cosmicdrift/kumiko-framework/secrets").SecretsContext;
34
- readonly _userId?: string | undefined;
34
+ readonly auditUserId?: string | undefined;
35
35
  readonly metricsFor?: (featureName: string) => MetricsHandle;
36
36
  };
37
37
 
@@ -1,6 +1,6 @@
1
1
  // @runtime client
2
2
  import { mergeTranslations, type TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
3
- import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
3
+ import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer/web";
4
4
  import { JOBS_FEATURE } from "../constants.ts";
5
5
  import { defaultTranslations } from "./i18n.ts";
6
6
 
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.376.0",
4
+ "type": "improvement",
5
+ "title": "Legal layout receives alternates",
6
+ "detail": "createLegalPagesLayout passes alternates as second argument to headerHtml and footerHtml, and legal-pages routes provide the other-language paths of the same slug, so a layout can render a language switch."
7
+ },
2
8
  {
3
9
  "version": "0.370.0",
4
10
  "type": "improvement",
@@ -4,7 +4,7 @@ import {
4
4
  type FeatureDefinition,
5
5
  SYSTEM_TENANT_ID,
6
6
  } from "@cosmicdrift/kumiko-framework/engine";
7
- import { escapeHtml } from "@cosmicdrift/kumiko-headless";
7
+ import { escapeHtml } from "@cosmicdrift/kumiko-renderer/headless";
8
8
  import {
9
9
  cachedSecurePageResponse,
10
10
  type PublicPageWrapLayout,
@@ -90,6 +90,17 @@ export type LegalPagesOptions = {
90
90
  readonly terms?: boolean;
91
91
  };
92
92
 
93
+ // Only slugs served in more than one language get alternates.
94
+ function alternatesBySlugOf(
95
+ routes: readonly LegalPageRoute[],
96
+ ): ReadonlyMap<string, Readonly<Record<string, string>>> {
97
+ const bySlug = new Map<string, Record<string, string>>();
98
+ for (const route of routes) {
99
+ bySlug.set(route.slug, { ...bySlug.get(route.slug), [route.lang]: route.path });
100
+ }
101
+ return new Map([...bySlug].filter(([, byLang]) => Object.keys(byLang).length > 1));
102
+ }
103
+
93
104
  export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDefinition {
94
105
  const wrapLayout = opts.wrapLayout ?? wrapInLayout;
95
106
  const routes: readonly LegalPageRoute[] = [
@@ -108,6 +119,7 @@ export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDe
108
119
  );
109
120
  }
110
121
  }
122
+ const alternatesBySlug = alternatesBySlugOf(routes);
111
123
  return defineFeature("legal-pages", (r) => {
112
124
  r.describe(
113
125
  "Opt-in wrapper around `template-resolver` text-blocks that registers public HTML routes (default: `/legal/impressum`, `/legal/datenschutz`, `/legal/imprint`, `/legal/privacy`) with Markdown-to-HTML rendering and a boot-time job that hard-fails in production when the required blocks (default: `imprint/de`, `privacy/de`) are not seeded in `SYSTEM_TENANT`. Both are configurable via `routes`/`requiredBlocks` for apps with a different default language or additional pages. Requires `anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID }` and `extraContext.templateResolver` to be wired at app bootstrap; for per-tenant imprints or a custom layout call `template-resolver:query:by-slug` directly.",
@@ -189,6 +201,7 @@ export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDe
189
201
  : renderedBody,
190
202
  lang: route.lang,
191
203
  slug: route.slug,
204
+ alternates: alternatesBySlug.get(route.slug),
192
205
  });
193
206
 
194
207
  return cachedSecurePageResponse(c.req.raw, {
@@ -1,4 +1,4 @@
1
- import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
1
+ import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-renderer/headless";
2
2
  import { type PublicPageWrapLayout, TENANT_CONTENT_ATTR } from "../page-render/index.ts";
3
3
 
4
4
  export type LegalPagesLayoutOptions = {
@@ -8,8 +8,8 @@ export type LegalPagesLayoutOptions = {
8
8
  readonly css?: string;
9
9
  readonly stylesheetHrefs?: readonly string[];
10
10
  // Trusted app chrome per language; the feature passes the route's `lang`.
11
- readonly headerHtml?: (lang: string) => string;
12
- readonly footerHtml?: (lang: string) => string;
11
+ readonly headerHtml?: (lang: string, alternates?: Readonly<Record<string, string>>) => string;
12
+ readonly footerHtml?: (lang: string, alternates?: Readonly<Record<string, string>>) => string;
13
13
  readonly contentClass?: string;
14
14
  };
15
15
 
@@ -22,9 +22,9 @@ export function createLegalPagesLayout(options: LegalPagesLayoutOptions): Public
22
22
  const contentClass = options.contentClass
23
23
  ? ` class="${escapeHtmlAttr(options.contentClass)}"`
24
24
  : "";
25
- return ({ title, bodyHtml, lang }) => {
26
- const headerHtml = options.headerHtml?.(lang) ?? "";
27
- const footerHtml = options.footerHtml?.(lang) ?? "";
25
+ return ({ title, bodyHtml, lang, alternates }) => {
26
+ const headerHtml = options.headerHtml?.(lang, alternates) ?? "";
27
+ const footerHtml = options.footerHtml?.(lang, alternates) ?? "";
28
28
  return `<!doctype html>
29
29
  <html lang="${escapeHtmlAttr(lang)}">
30
30
  <head>
@@ -17,7 +17,7 @@
17
17
  // fehlende Pflicht-Blocks.
18
18
 
19
19
  import type { TreeChildrenSubscribe, TreeNode } from "@cosmicdrift/kumiko-framework/engine";
20
- import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
20
+ import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer/web";
21
21
  import { LEGAL_OPTIONAL_BLOCKS, LEGAL_REQUIRED_BLOCKS } from "../constants.ts";
22
22
 
23
23
  const treeProvider: TreeChildrenSubscribe = () => (emit) => {
@@ -58,34 +58,33 @@ export const MAIL_TRANSPORT_EXTENSION = "mailTransport" as const;
58
58
  // =============================================================================
59
59
 
60
60
  /**
61
- * Schmaler Surface-Type fuer Transport-Plugins — gespiegelt von
62
- * file-foundation's FileProviderContext. HandlerContext ist zu fett
63
- * (haelt tx, actor, signal etc.); Plugins beschraenken sich auf die
64
- * read-Felder die fuer Tenant-Config + Secret-Lookup noetig sind.
61
+ * Narrow surface type for transport plugins, mirroring file-foundation's
62
+ * FileProviderContext. HandlerContext carries tx, actor, signal and more;
63
+ * plugins only get the read fields needed for tenant config and secret lookup.
65
64
  *
66
- * **Warum nicht voller HandlerContext?** Im Worker-Pfad (r.job-getriggerte
67
- * Transport-Builds, z.B. der user-data-rights forget/export-Cron) gibt es
68
- * keinen per-request `config`/`tx`/`actor` — der Wrapper baut den per-Tenant-
69
- * ConfigAccessor aus `ctx.configResolver`. Ein Plugin das `ctx.tx` oder
70
- * andere request-only-Felder liest, wuerde den Worker-Pfad zur Runtime
71
- * brechen — und das fiele NUR mit echtem SMTP und nur in production auf.
72
- * Cast `ctx as unknown as HandlerContext` macht den Compiler happy, fliegt
73
- * aber zur Runtime im Worker. Plugin das mehr braucht: MailTransportContext
74
- * explizit erweitern (sichtbarer breaking change) statt ctx-cast.
65
+ * **Why not the full HandlerContext?** On the worker path (transport builds
66
+ * triggered by r.job, such as the user-data-rights forget/export cron) there
67
+ * is no per-request `config`/`tx`/`actor`; the wrapper builds the per-tenant
68
+ * ConfigAccessor from `ctx.configResolver`. A plugin reading `ctx.tx` or other
69
+ * request-only fields would break the worker path at runtime, and that would
70
+ * only show up with real SMTP in production. Casting
71
+ * `ctx as unknown as HandlerContext` satisfies the compiler but fails at
72
+ * runtime in the worker. A plugin that needs more extends MailTransportContext
73
+ * explicitly (a visible breaking change) instead of casting ctx.
75
74
  *
76
- * **Felder:**
77
- * config — tenant-config-reads (host/port/from/... der Plugins)
78
- * registry — extension-Lookup in der Factory (nicht plugin-intern)
79
- * secrets — tenant-secret-reads (smtp.password)
80
- * _userId — Audit-Identity fuer secret-reads. Handler-Pfad: dispatcher
81
- * setzt Caller-User-ID; Worker-Pfad: r.job-Wrap setzt eine
82
- * System-Identity.
75
+ * **Fields:**
76
+ * config — tenant config reads (the plugin's host/port/from/...)
77
+ * registry — extension lookup in the factory (not plugin-internal)
78
+ * secrets — tenant secret reads (smtp.password)
79
+ * auditUserId — audit identity for secret reads. Handler path: the
80
+ * dispatcher sets the caller's user id; worker path: the
81
+ * r.job wrap sets a system identity.
83
82
  */
84
83
  export type MailTransportContext = {
85
84
  readonly config?: ConfigAccessor;
86
85
  readonly registry?: Registry;
87
86
  readonly secrets?: import("@cosmicdrift/kumiko-framework/secrets").SecretsContext;
88
- readonly _userId?: string | undefined;
87
+ readonly auditUserId?: string | undefined;
89
88
  };
90
89
 
91
90
  /**
@@ -283,7 +282,7 @@ export function tenantMailTransport(
283
282
  config: scope.config,
284
283
  registry: scope.registry,
285
284
  secrets: scope.secrets,
286
- _userId: createSystemUser(scope.tenantId).id,
285
+ auditUserId: createSystemUser(scope.tenantId).id,
287
286
  },
288
287
  scope.tenantId,
289
288
  "channel-email",
@@ -6,7 +6,7 @@
6
6
  // [managedPagesClient()] }), damit die Admin-Labels übersetzt rendern.
7
7
 
8
8
  import { mergeTranslations, type TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
9
- import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
9
+ import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer/web";
10
10
  import { defaultTranslations } from "./i18n.ts";
11
11
 
12
12
  export type ManagedPagesClientOptions = {
@@ -1,6 +1,6 @@
1
1
  // @runtime client
2
2
 
3
- import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
3
+ import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer/web";
4
4
  import { NOTES_HISTORY_FEATURE_NAME, NOTES_SECTION_EXTENSION_NAME } from "../constants.ts";
5
5
  import { defaultTranslations } from "./i18n.ts";
6
6
  import { NotesSection } from "./notes-section.tsx";
@@ -1,4 +1,4 @@
1
- import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
1
+ import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-renderer/headless";
2
2
 
3
3
  // Tenant-branding tokens resolved at render time (managed-pages reads them
4
4
  // from config — see managed-pages/branding.ts). Empty string = "unset, use