@cosmicdrift/kumiko-samples 0.340.0 → 0.342.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 (124) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +6 -1
  3. package/packages/bundled-features/src/audit/constants.ts +5 -0
  4. package/packages/bundled-features/src/audit/feature.ts +47 -6
  5. package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +53 -0
  6. package/packages/bundled-features/src/auth-email-password/bootstrap.ts +325 -0
  7. package/packages/bundled-features/src/auth-email-password/changes.json +30 -0
  8. package/packages/bundled-features/src/auth-email-password/constants.ts +5 -0
  9. package/packages/bundled-features/src/auth-email-password/errors.ts +9 -0
  10. package/packages/bundled-features/src/auth-email-password/feature.ts +2 -0
  11. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +13 -1
  12. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +12 -0
  13. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +210 -113
  14. package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +6 -1
  15. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +1 -0
  16. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +1 -0
  17. package/packages/bundled-features/src/auth-email-password/index.ts +13 -0
  18. package/packages/bundled-features/src/auth-email-password/invite-token-store.ts +6 -0
  19. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +1 -0
  20. package/packages/bundled-features/src/auth-email-password/web/auth-form-primitives.tsx +24 -6
  21. package/packages/bundled-features/src/auth-email-password/web/confirm-account-unlock-screen.tsx +12 -18
  22. package/packages/bundled-features/src/auth-email-password/web/forgot-password-screen.tsx +4 -4
  23. package/packages/bundled-features/src/auth-email-password/web/invite-accept-screen.tsx +65 -69
  24. package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +93 -95
  25. package/packages/bundled-features/src/auth-email-password/web/request-account-unlock-screen.tsx +4 -4
  26. package/packages/bundled-features/src/auth-email-password/web/reset-password-screen.tsx +8 -10
  27. package/packages/bundled-features/src/auth-email-password/web/session-bootstrap-error.tsx +13 -15
  28. package/packages/bundled-features/src/auth-email-password/web/signup-complete-screen.tsx +43 -49
  29. package/packages/bundled-features/src/auth-email-password/web/signup-screen.tsx +4 -4
  30. package/packages/bundled-features/src/auth-email-password/web/verify-email-screen.tsx +12 -18
  31. package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
  32. package/packages/bundled-features/src/auth-mfa/config.ts +12 -3
  33. package/packages/bundled-features/src/auth-mfa/feature.ts +7 -2
  34. package/packages/bundled-features/src/auth-mfa/mfa-status-checker.ts +2 -1
  35. package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +83 -85
  36. package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +24 -26
  37. package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
  38. package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +1 -0
  39. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +1 -0
  40. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-notify.ts +1 -0
  41. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +83 -34
  42. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +2 -0
  43. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +4 -1
  44. package/packages/bundled-features/src/channel-discord/discord-channel.ts +5 -0
  45. package/packages/bundled-features/src/channel-discord/feature.ts +16 -1
  46. package/packages/bundled-features/src/channel-discord/index.ts +7 -1
  47. package/packages/bundled-features/src/channel-email/changes.json +6 -0
  48. package/packages/bundled-features/src/channel-email/email-channel.ts +9 -1
  49. package/packages/bundled-features/src/channel-email/pii-guard.ts +6 -5
  50. package/packages/bundled-features/src/channel-email/smtp-transport.ts +27 -1
  51. package/packages/bundled-features/src/channel-email/types.ts +4 -0
  52. package/packages/bundled-features/src/channel-slack/feature.ts +12 -1
  53. package/packages/bundled-features/src/channel-slack/index.ts +6 -1
  54. package/packages/bundled-features/src/channel-slack/slack-channel.ts +5 -0
  55. package/packages/bundled-features/src/channel-teams/feature.ts +12 -1
  56. package/packages/bundled-features/src/channel-teams/index.ts +6 -1
  57. package/packages/bundled-features/src/channel-teams/teams-channel.ts +5 -0
  58. package/packages/bundled-features/src/channel-telegram/changes.json +9 -0
  59. package/packages/bundled-features/src/channel-telegram/feature.ts +8 -2
  60. package/packages/bundled-features/src/channel-telegram/index.ts +8 -1
  61. package/packages/bundled-features/src/channel-telegram/telegram-channel.ts +39 -11
  62. package/packages/bundled-features/src/delivery/changes.json +7 -0
  63. package/packages/bundled-features/src/delivery/chat-webhook-channel.ts +18 -5
  64. package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +19 -10
  65. package/packages/bundled-features/src/delivery/delivery-service.ts +78 -32
  66. package/packages/bundled-features/src/delivery/index.ts +4 -0
  67. package/packages/bundled-features/src/delivery/jobs.ts +3 -1
  68. package/packages/bundled-features/src/delivery/types.ts +10 -4
  69. package/packages/bundled-features/src/files/changes.json +13 -1
  70. package/packages/bundled-features/src/legal-pages/README.md +26 -3
  71. package/packages/bundled-features/src/legal-pages/feature.ts +27 -85
  72. package/packages/bundled-features/src/legal-pages/lib/boot-check.ts +57 -0
  73. package/packages/bundled-features/src/legal-pages/lib/validate-routes.ts +21 -0
  74. package/packages/bundled-features/src/page-render/index.ts +2 -0
  75. package/packages/bundled-features/src/page-render/layout.ts +17 -0
  76. package/packages/bundled-features/src/page-render/trailing-slash.ts +15 -0
  77. package/packages/bundled-features/src/renderer-simple/changes.json +6 -0
  78. package/packages/bundled-features/src/renderer-simple/feature.ts +1 -0
  79. package/packages/bundled-features/src/renderer-simple/index.ts +6 -1
  80. package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +103 -19
  81. package/packages/bundled-features/src/secrets/announce-secret-change.ts +30 -0
  82. package/packages/bundled-features/src/secrets/changes.json +12 -0
  83. package/packages/bundled-features/src/secrets/feature.ts +1 -1
  84. package/packages/bundled-features/src/secrets/handlers/delete.write.ts +2 -0
  85. package/packages/bundled-features/src/secrets/handlers/set.write.ts +5 -1
  86. package/packages/bundled-features/src/secrets/i18n.ts +4 -0
  87. package/packages/bundled-features/src/secrets/secrets-context.ts +9 -0
  88. package/packages/bundled-features/src/secrets/write-gate.ts +41 -0
  89. package/packages/bundled-features/src/tenant/changes.json +6 -0
  90. package/packages/bundled-features/src/tenant/constants.ts +1 -0
  91. package/packages/bundled-features/src/tenant/handlers/add-member.write.ts +27 -3
  92. package/packages/bundled-features/src/tenant/handlers/invitations.query.ts +15 -1
  93. package/packages/bundled-features/src/tenant/index.ts +1 -0
  94. package/packages/bundled-features/src/tenant/invitation-table.ts +10 -0
  95. package/packages/bundled-features/src/tenant-lifecycle/lifecycle-gate.ts +2 -0
  96. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +2 -2
  97. package/packages/bundled-features/src/tier-engine/feature.ts +92 -14
  98. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +18 -13
  99. package/packages/bundled-features/src/user/invited-global-roles.ts +58 -0
  100. package/packages/bundled-features/src/user-data-rights/feature.ts +3 -0
  101. package/packages/bundled-features/src/waitlist/changes.json +23 -0
  102. package/packages/bundled-features/src/waitlist/constants.ts +44 -0
  103. package/packages/bundled-features/src/waitlist/entity.ts +76 -0
  104. package/packages/bundled-features/src/waitlist/feature.ts +50 -0
  105. package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +175 -0
  106. package/packages/bundled-features/src/waitlist/handlers/list.query.ts +12 -0
  107. package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +120 -0
  108. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +163 -0
  109. package/packages/bundled-features/src/waitlist/i18n.ts +33 -0
  110. package/packages/bundled-features/src/waitlist/index.ts +15 -0
  111. package/packages/bundled-features/src/waitlist/lib.ts +36 -0
  112. package/packages/bundled-features/src/waitlist/mail.ts +114 -0
  113. package/packages/bundled-features/src/waitlist/options.ts +42 -0
  114. package/packages/bundled-features/src/waitlist/payloads.ts +55 -0
  115. package/packages/bundled-features/src/waitlist/screens.ts +43 -0
  116. package/packages/bundled-features/src/waitlist/user-data-hooks.ts +82 -0
  117. package/samples/apps/use-all-bundled/feature-manifest.json +63 -7
  118. package/samples/apps/use-all-bundled/kumiko/migrations/0033_waitlist.rebuild.json +4 -0
  119. package/samples/apps/use-all-bundled/schema/generate.ts +5 -0
  120. package/samples/apps/use-all-bundled/schema/schema.generated.ts +3 -0
  121. package/samples/apps/use-all-bundled/src/run-config.ts +2 -0
  122. package/samples/recipes/chat-channels/README.md +2 -0
  123. package/samples/recipes/managed-config/README.md +1 -1
  124. package/samples/recipes/rate-limiting/README.md +21 -0
@@ -1 +1,13 @@
1
- []
1
+ [
2
+ {
3
+ "version": "0.341.0",
4
+ "type": "improvement",
5
+ "title": "Uploads resolve a generic content type from the file content",
6
+ "detail": "POST /api/files now resolves application/octet-stream or a missing type from the bytes (PDF, XML, PNG, JPEG, GIF, WebP, ZIP) and falls back to the file extension. A concrete declared type is never overridden. The shared helper resolveContentType is exported from the files module."
7
+ },
8
+ {
9
+ "version": "0.341.0",
10
+ "type": "fix",
11
+ "title": "Uploads without entityId no longer produce an empty storage key segment"
12
+ }
13
+ ]
@@ -71,9 +71,32 @@ Response:
71
71
  - `404 text/plain` — block missing. Hint: "Tenant admin must set this text block".
72
72
  - `503 text/plain` — `app.fetch` to `/api/query` failed (anonymousAccess missing?).
73
73
 
74
- Layout: a minimal HTML5 skeleton with inline CSS — apps that want to
75
- integrate into their own layout use `template-resolver:query:by-slug`
76
- directly and render themselves.
74
+ Layout: a minimal HTML5 skeleton with inline CSS. Apps that want their own
75
+ header, footer and theme pass `createLegalPagesFeature({ wrapLayout })`, a
76
+ `PublicPageWrapLayout` (`@cosmicdrift/kumiko-bundled-features/page-render`):
77
+ `({ title, bodyHtml, lang, slug?, alternates? }) => string`. The framework
78
+ keeps setting the security headers; the layout only returns the HTML.
79
+
80
+ The page CSP is `script-src 'none'`, so the layout must not need JavaScript
81
+ (a language switch has to be plain links).
82
+
83
+ Trailing slash: every route also answers its other slash form
84
+ (`/legal/impressum/`) with a `301` to the configured path, for `GET` and `HEAD`,
85
+ query string kept. A path configured with a trailing slash redirects the form
86
+ without it.
87
+
88
+ ### Contract termination pages
89
+
90
+ `createContractTerminationRoutes({ paths?, wrapLayout? })` (billing-foundation,
91
+ consumer protection) serves `/legal/kuendigen` and `/legal/cancel`. It takes the
92
+ same `wrapLayout` and wraps every page with it: form, review, result, 429 and
93
+ error. `alternates` maps `de`/`en` to the configured paths (custom `paths`
94
+ included), so the layout can link to the other language. The body sits in
95
+ `<div data-kumiko-page="contract-termination">` with `data-kumiko-*` hooks on
96
+ the forms, field groups, buttons and review table for app CSS. The headers (CSP
97
+ with `script-src 'none'`, `frame-ancestors 'none'`, `X-Frame-Options: DENY`,
98
+ `no-store`) are the same with and without `wrapLayout`, so a layout that loads
99
+ scripts fails. The slash redirect applies here too; `POST` has no alias.
77
100
 
78
101
  ---
79
102
 
@@ -1,23 +1,27 @@
1
- import {
2
- requireTemplateResolver,
3
- TEXT_BLOCK_KIND,
4
- type TemplateResolverApi,
5
- } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
6
1
  import { computeRevisionEtag, etagMatches } from "@cosmicdrift/kumiko-framework/api";
7
2
  import {
8
3
  defineFeature,
9
4
  type FeatureDefinition,
10
5
  SYSTEM_TENANT_ID,
11
6
  } from "@cosmicdrift/kumiko-framework/engine";
12
- import { cachedSecurePageResponse } from "../page-render/index.js";
7
+ import {
8
+ cachedSecurePageResponse,
9
+ type PublicPageWrapLayout,
10
+ redirectToCanonicalPath,
11
+ slashVariantOf,
12
+ } from "../page-render/index.js";
13
13
  import {
14
14
  LEGAL_REQUIRED_BLOCKS,
15
15
  LEGAL_ROUTES,
16
16
  type LegalPageRoute,
17
17
  type LegalRequiredBlock,
18
18
  } from "./constants.js";
19
+ import { runLegalPagesBootCheck } from "./lib/boot-check.js";
20
+ import { validateRoutes } from "./lib/validate-routes.js";
19
21
  import { renderMarkdownToHtml, wrapInLayout } from "./markdown.js";
20
22
 
23
+ export { type LegalPagesBootCheckCtx, runLegalPagesBootCheck } from "./lib/boot-check.js";
24
+
21
25
  // QN-Konstante als dokumentierter Public-Contract des template-resolver-
22
26
  // Features. Ein magic-string statt eines Code-Imports ist hier explizit
23
27
  // gewollt: Cross-Feature-Calls gehen nur über stable Public-API
@@ -59,12 +63,7 @@ const PUBLIC_PAGE_CACHE = { kind: "revalidate", maxAgeSeconds: 60 } as const;
59
63
  // setzen — sonst wirft Boot-Check beim Start
60
64
  // • anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID } — sonst
61
65
  // antworten die Routes mit 503
62
- export type LegalPagesWrapLayout = (opts: {
63
- readonly title: string;
64
- readonly bodyHtml: string;
65
- readonly lang: string;
66
- readonly slug?: string;
67
- }) => string;
66
+ export type LegalPagesWrapLayout = PublicPageWrapLayout;
68
67
 
69
68
  export type LegalPagesOptions = {
70
69
  /** Custom Layout-Wrapper für die /legal/*-Routes. Default: minimaler
@@ -86,26 +85,6 @@ export type LegalPagesOptions = {
86
85
  readonly requiredBlocks?: readonly LegalRequiredBlock[];
87
86
  };
88
87
 
89
- // Validated once at feature-build time (not per-request) so a config typo
90
- // fails app startup instead of surfacing as a confusing 404 later.
91
- function validateRoutes(routes: readonly LegalPageRoute[]): void {
92
- const seenPaths = new Set<string>();
93
- for (const route of routes) {
94
- if (!route.path || !route.slug || !route.lang) {
95
- throw new Error(
96
- `legal-pages: route is missing a required field (path/slug/lang): ${JSON.stringify(route)}`,
97
- );
98
- }
99
- if (!route.path.startsWith("/")) {
100
- throw new Error(`legal-pages: route path must start with "/", got "${route.path}"`);
101
- }
102
- if (seenPaths.has(route.path)) {
103
- throw new Error(`legal-pages: duplicate route path "${route.path}"`);
104
- }
105
- seenPaths.add(route.path);
106
- }
107
- }
108
-
109
88
  export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDefinition {
110
89
  const wrapLayout = opts.wrapLayout ?? wrapInLayout;
111
90
  const routes = opts.routes ?? LEGAL_ROUTES;
@@ -138,6 +117,8 @@ export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDe
138
117
  method: "GET",
139
118
  path: route.path,
140
119
  anonymous: true,
120
+ // Public pages backed by a DB query: bound the per-IP read rate.
121
+ rateLimit: { per: "ip+handler", limit: 60, windowSeconds: 60 },
141
122
  handler: async (c, { systemQuery }) => {
142
123
  // Architektur: 1 App = X Tenants = 1 Impressum. Egal welche
143
124
  // Subdomain der Visitor besucht (apex, admin.*, tenant-x.*) —
@@ -208,6 +189,20 @@ export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDe
208
189
  });
209
190
  }
210
191
 
192
+ // Hono matches strictly, so "/legal/x/" would 404 for sites that link with
193
+ // a trailing slash. GET also serves HEAD.
194
+ const configuredPaths = new Set(routes.map((route) => route.path));
195
+ for (const route of routes) {
196
+ const variant = slashVariantOf(route.path);
197
+ if (variant === null || configuredPaths.has(variant)) continue;
198
+ r.httpRoute({
199
+ method: "GET",
200
+ path: variant,
201
+ anonymous: true,
202
+ handler: (c) => redirectToCanonicalPath(c.req.url, route.path),
203
+ });
204
+ }
205
+
211
206
  // Boot-Check via ctx.templateResolver (extraContext-Pattern, symmetrisch
212
207
  // zu requireConfigResolver in config). App-Bootstrap muss templateResolver
213
208
  // wired haben — der Helper gibt einen klaren Wiring-Hinweis wenn nicht.
@@ -232,56 +227,3 @@ export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDe
232
227
  return {};
233
228
  });
234
229
  }
235
-
236
- // Minimal-shape für die Boot-Check-Logik — nur die Felder die der Check
237
- // braucht. Akzeptiert HandlerContext + AppContext + jeden anderen
238
- // Container der templateResolver + log mitbringt. Macht den Check direkt
239
- // unit-testbar mit constructed ctx-Objects.
240
- export type LegalPagesBootCheckCtx = {
241
- readonly templateResolver?: TemplateResolverApi;
242
- readonly log?: {
243
- readonly info?: (msg: string) => void;
244
- readonly warn?: (msg: string) => void;
245
- };
246
- };
247
-
248
- // Exportiert für direkte Tests. Wirft InternalError wenn ctx.templateResolver
249
- // nicht gewired ist (Hinweis auf fehlenden extraContext). Wirft Error
250
- // in NODE_ENV=production wenn Pflicht-Blocks fehlen, sonst log.warn.
251
- // Logged log.info wenn alles vorhanden ist (kein silent-skip).
252
- export async function runLegalPagesBootCheck(
253
- ctx: LegalPagesBootCheckCtx,
254
- requiredBlocks: readonly LegalRequiredBlock[] = LEGAL_REQUIRED_BLOCKS,
255
- ): Promise<void> {
256
- const templateResolver: TemplateResolverApi = requireTemplateResolver(
257
- ctx,
258
- "legal-pages-boot-check",
259
- );
260
- const missing: { slug: string; lang: string }[] = [];
261
-
262
- for (const required of requiredBlocks) {
263
- const block = await templateResolver.findExact({
264
- tenantId: SYSTEM_TENANT_ID,
265
- slug: required.slug,
266
- kind: TEXT_BLOCK_KIND,
267
- locale: required.lang,
268
- });
269
- if (!block?.content) {
270
- missing.push({ slug: required.slug, lang: required.lang });
271
- }
272
- }
273
-
274
- if (missing.length === 0) {
275
- ctx.log?.info?.("legal-pages boot-check: alle Pflicht-Blocks vorhanden");
276
- } else {
277
- const message =
278
- `legal-pages: missing ${missing.length} required text-block(s) in SYSTEM_TENANT: ` +
279
- missing.map((m) => `${m.slug}/${m.lang}`).join(", ") +
280
- ". Seed via template-resolver:write:set or the seedTextBlock helper.";
281
-
282
- if (process.env["NODE_ENV"] === "production") {
283
- throw new Error(`Boot-Validation failed: ${message}`);
284
- }
285
- ctx.log?.warn?.(message);
286
- }
287
- }
@@ -0,0 +1,57 @@
1
+ import {
2
+ requireTemplateResolver,
3
+ TEXT_BLOCK_KIND,
4
+ type TemplateResolverApi,
5
+ } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
6
+ import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
7
+ import { LEGAL_REQUIRED_BLOCKS, type LegalRequiredBlock } from "../constants.js";
8
+
9
+ // Only the fields the check reads, so HandlerContext, AppContext or a hand-built
10
+ // object in a unit test all fit.
11
+ export type LegalPagesBootCheckCtx = {
12
+ readonly templateResolver?: TemplateResolverApi;
13
+ readonly log?: {
14
+ readonly info?: (msg: string) => void;
15
+ readonly warn?: (msg: string) => void;
16
+ };
17
+ };
18
+
19
+ // Missing required blocks fail the boot only in production; elsewhere they warn,
20
+ // so a dev setup without seeded legal texts still starts. A complete set logs
21
+ // info on purpose, so a skipped check is visible.
22
+ export async function runLegalPagesBootCheck(
23
+ ctx: LegalPagesBootCheckCtx,
24
+ requiredBlocks: readonly LegalRequiredBlock[] = LEGAL_REQUIRED_BLOCKS,
25
+ ): Promise<void> {
26
+ const templateResolver: TemplateResolverApi = requireTemplateResolver(
27
+ ctx,
28
+ "legal-pages-boot-check",
29
+ );
30
+ const missing: { slug: string; lang: string }[] = [];
31
+
32
+ for (const required of requiredBlocks) {
33
+ const block = await templateResolver.findExact({
34
+ tenantId: SYSTEM_TENANT_ID,
35
+ slug: required.slug,
36
+ kind: TEXT_BLOCK_KIND,
37
+ locale: required.lang,
38
+ });
39
+ if (!block?.content) {
40
+ missing.push({ slug: required.slug, lang: required.lang });
41
+ }
42
+ }
43
+
44
+ if (missing.length === 0) {
45
+ ctx.log?.info?.("legal-pages boot-check: alle Pflicht-Blocks vorhanden");
46
+ } else {
47
+ const message =
48
+ `legal-pages: missing ${missing.length} required text-block(s) in SYSTEM_TENANT: ` +
49
+ missing.map((m) => `${m.slug}/${m.lang}`).join(", ") +
50
+ ". Seed via template-resolver:write:set or the seedTextBlock helper.";
51
+
52
+ if (process.env["NODE_ENV"] === "production") {
53
+ throw new Error(`Boot-Validation failed: ${message}`);
54
+ }
55
+ ctx.log?.warn?.(message);
56
+ }
57
+ }
@@ -0,0 +1,21 @@
1
+ import type { LegalPageRoute } from "../constants.js";
2
+
3
+ // Validated once at feature-build time (not per-request) so a config typo
4
+ // fails app startup instead of surfacing as a confusing 404 later.
5
+ export function validateRoutes(routes: readonly LegalPageRoute[]): void {
6
+ const seenPaths = new Set<string>();
7
+ for (const route of routes) {
8
+ if (!route.path || !route.slug || !route.lang) {
9
+ throw new Error(
10
+ `legal-pages: route is missing a required field (path/slug/lang): ${JSON.stringify(route)}`,
11
+ );
12
+ }
13
+ if (!route.path.startsWith("/")) {
14
+ throw new Error(`legal-pages: route path must start with "/", got "${route.path}"`);
15
+ }
16
+ if (seenPaths.has(route.path)) {
17
+ throw new Error(`legal-pages: duplicate route path "${route.path}"`);
18
+ }
19
+ seenPaths.add(route.path);
20
+ }
21
+ }
@@ -13,6 +13,7 @@ export {
13
13
  } from "./cached-page-response.js";
14
14
  export { sanitizeTenantCss } from "./css-sanitize.js";
15
15
  export {
16
+ type PublicPageWrapLayout,
16
17
  type SeoHeadInput,
17
18
  TENANT_CONTENT_ATTR,
18
19
  tenantStyleBlock,
@@ -20,3 +21,4 @@ export {
20
21
  } from "./layout.js";
21
22
  export { renderSafeMarkdown } from "./markdown.js";
22
23
  export { securePageHeaders } from "./security-headers.js";
24
+ export { redirectToCanonicalPath, slashVariantOf } from "./trailing-slash.js";
@@ -8,6 +8,18 @@ import { sanitizeTenantCss } from "./css-sanitize.js";
8
8
  // wrapInLayout's own opts, so callers don't repeat them).
9
9
  export type SeoHeadInput = Omit<ApexHead, "title" | "description" | "lang">;
10
10
 
11
+ // Signature a public FW page (legal-pages, contract termination) accepts for
12
+ // an app-supplied layout. `alternates` maps a locale to the path of the same
13
+ // page in that locale, so the layout can render a language switch from plain
14
+ // links (the page CSP has script-src 'none').
15
+ export type PublicPageWrapLayout = (opts: {
16
+ readonly title: string;
17
+ readonly bodyHtml: string;
18
+ readonly lang: string;
19
+ readonly slug?: string;
20
+ readonly alternates?: Readonly<Record<string, string>>;
21
+ }) => string;
22
+
11
23
  // Attribute marking the content container. The page body lives in
12
24
  // `<main data-tenant-content>`; tenant custom CSS is scoped to its descendants
13
25
  // and host containment clips its paint to this box. A custom wrapLayout that
@@ -98,6 +110,11 @@ ${headHtml}
98
110
  a { color: var(--accent); }
99
111
  code { background: #f4f4f4; padding: 0.1rem 0.3rem; border-radius: 3px; }
100
112
  hr { border: 0; border-top: 1px solid #ddd; margin: 2rem 0; }
113
+ input, select, textarea, button { font: inherit; color: inherit; padding: 0.4rem 0.6rem;
114
+ border: 1px solid #bbb; border-radius: 4px; box-sizing: border-box; }
115
+ input:not([type="radio"]):not([type="checkbox"]):not([type="hidden"]), select, textarea {
116
+ width: 100%; max-width: 100%; }
117
+ button { cursor: pointer; background: #f4f4f4; }
101
118
  .brand-header { position: relative; z-index: 1; display: flex; align-items: center;
102
119
  gap: 0.6rem; margin-bottom: 1.5rem; }
103
120
  .brand-header a { display: flex; align-items: center; gap: 0.6rem; color: inherit; text-decoration: none; }
@@ -0,0 +1,15 @@
1
+ // The other trailing-slash form of a path, or null for the root path (it has
2
+ // no distinct variant).
3
+ export function slashVariantOf(path: string): string | null {
4
+ if (path === "/") return null;
5
+ return path.endsWith("/") ? path.slice(0, -1) : `${path}/`;
6
+ }
7
+
8
+ // Hono matches strictly, so the slash variant of a page would 404. A 301 keeps
9
+ // one canonical URL; the query string is carried over.
10
+ export function redirectToCanonicalPath(requestUrl: string, canonicalPath: string): Response {
11
+ return new Response(null, {
12
+ status: 301,
13
+ headers: { location: `${canonicalPath}${new URL(requestUrl).search}` },
14
+ });
15
+ }
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.341.0",
4
+ "type": "improvement",
5
+ "title": "Localized mail branding, locale through delivery, logoPath",
6
+ "detail": "`ctx.notify` accepts `locale`, and the locale now travels through the delivery jobs (`delivery.render`, `delivery.send`) to `NotificationRenderer.render`, where `RendererInput.locale` is available. Jobs queued before this release have no locale and keep working. The email channel and `renderer-simple` pass it on; the auth mails and the consumer-protection mails set it to the language they already translate their content into. This lands in the same release as the `NotifyOptions` change from #3508.\n`MailBranding` for `renderer-simple` can now be localized: `footerText`, `footerLinks[].label` and `footerLinks[].url` take a plain string or a map of locale to string. The renderer picks the exact locale, then the language part (`de-AT` falls back to `de`), then the new optional `MailBranding.defaultLocale`, then the first entry. Every URL in a map must be an absolute http(s) URL, otherwise `createSimpleRenderer` throws at boot. Without a locale and with plain strings the HTML is unchanged.\n`MailBranding` also gets `logoPath` and `baseUrl`, so an app can point at a raster logo on its own origin (`logoPath: \"/logo.png\"`, `baseUrl` the same base as `auth.mail.baseUrl`). `logoPath` and `logoUrl` exclude each other, `logoPath` needs a valid `baseUrl`, must start with a single `/` and must stay on the base origin; violations throw at boot. Use PNG or JPEG, because mail clients block SVG."
7
+ },
2
8
  {
3
9
  "version": "0.339.0",
4
10
  "type": "improvement",
@@ -34,6 +34,7 @@ export async function adaptToFoundation(
34
34
  const html = await renderer.render({
35
35
  template: req.payload.template ?? "",
36
36
  variables,
37
+ locale: req.payload.locale,
37
38
  });
38
39
  return { kind: "notification", html };
39
40
  }
@@ -1,2 +1,7 @@
1
1
  export { createRendererSimpleFeature, type RendererSimpleOptions } from "./feature.js";
2
- export { createSimpleRenderer, type MailBranding, simpleRenderer } from "./simple-renderer.js";
2
+ export {
3
+ createSimpleRenderer,
4
+ type LocalizedText,
5
+ type MailBranding,
6
+ simpleRenderer,
7
+ } from "./simple-renderer.js";
@@ -6,13 +6,24 @@ export type MailBranding = {
6
6
  readonly productName?: string;
7
7
  /** Hex color (#rgb, #rrggbb, #rrggbbaa); used for buttons and the header accent. */
8
8
  readonly primaryColor?: string;
9
- /** Absolute http(s) URL; anything else is dropped. */
9
+ /** Absolute http(s) URL; anything else is dropped. Mutually exclusive with `logoPath`. */
10
10
  readonly logoUrl?: string;
11
- readonly footerText?: string;
12
- /** Absolute http(s) URLs only; other entries are dropped. */
13
- readonly footerLinks?: readonly { readonly label: string; readonly url: string }[];
11
+ /** Absolute path on `baseUrl` (e.g. "/logo.png"); use PNG/JPEG, mail clients block SVG. */
12
+ readonly logoPath?: string;
13
+ /** Absolute http(s) origin that `logoPath` is resolved against; same base as `auth.mail.baseUrl`. */
14
+ readonly baseUrl?: string;
15
+ /** Locale tried after the exact and language-part match when a localized field has no entry. */
16
+ readonly defaultLocale?: string;
17
+ readonly footerText?: LocalizedText;
18
+ /** Absolute http(s) URLs only; string URLs that are not are dropped, localized ones fail at boot. */
19
+ readonly footerLinks?: readonly {
20
+ readonly label: LocalizedText;
21
+ readonly url: LocalizedText;
22
+ }[];
14
23
  };
15
24
 
25
+ export type LocalizedText = string | Readonly<Record<string, string>>;
26
+
16
27
  const DEFAULT_PRIMARY_COLOR = "#2563eb";
17
28
  const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
18
29
 
@@ -25,14 +36,79 @@ function isHttpUrl(value: string): boolean {
25
36
  }
26
37
  }
27
38
 
39
+ function languagePart(locale: string): string {
40
+ return locale.split("-")[0] ?? locale;
41
+ }
42
+
43
+ function pickByLocale(
44
+ entries: Readonly<Record<string, string>>,
45
+ locale: string,
46
+ ): string | undefined {
47
+ // Own keys only: a locale like "constructor" must not hit Object.prototype.
48
+ const ownEntry = (key: string): string | undefined =>
49
+ Object.hasOwn(entries, key) ? entries[key] : undefined;
50
+ return ownEntry(locale) ?? ownEntry(languagePart(locale));
51
+ }
52
+
53
+ function resolveLocalized(
54
+ value: LocalizedText | undefined,
55
+ locale: string | undefined,
56
+ defaultLocale: string | undefined,
57
+ ): string | undefined {
58
+ if (value === undefined || typeof value === "string") return value;
59
+ return (
60
+ (locale !== undefined ? pickByLocale(value, locale) : undefined) ??
61
+ (defaultLocale !== undefined ? pickByLocale(value, defaultLocale) : undefined) ??
62
+ Object.values(value)[0]
63
+ );
64
+ }
65
+
66
+ function assertLocalizedUrlsAreHttp(branding: MailBranding): void {
67
+ for (const link of branding.footerLinks ?? []) {
68
+ if (typeof link.url === "string") continue;
69
+ for (const [locale, url] of Object.entries(link.url)) {
70
+ if (!isHttpUrl(url)) {
71
+ throw new Error(
72
+ `createSimpleRenderer: footerLinks url for locale "${locale}" must be an absolute http(s) URL`,
73
+ );
74
+ }
75
+ }
76
+ }
77
+ }
78
+
79
+ function resolveLogoUrl(branding: MailBranding | undefined): string | undefined {
80
+ if (!branding) return undefined;
81
+ const { logoUrl, logoPath, baseUrl } = branding;
82
+ if (logoPath === undefined) {
83
+ return logoUrl && isHttpUrl(logoUrl) ? logoUrl : undefined;
84
+ }
85
+ if (logoUrl !== undefined) {
86
+ throw new Error("createSimpleRenderer: logoPath and logoUrl are mutually exclusive");
87
+ }
88
+ if (baseUrl === undefined || !isHttpUrl(baseUrl)) {
89
+ throw new Error("createSimpleRenderer: logoPath requires an absolute http(s) baseUrl");
90
+ }
91
+ if (!/^\/(?![/\\])/.test(logoPath) || logoPath.includes("\\")) {
92
+ throw new Error('createSimpleRenderer: logoPath must start with a single "/"');
93
+ }
94
+ const resolved = new URL(logoPath, baseUrl);
95
+ if (resolved.origin !== new URL(baseUrl).origin) {
96
+ throw new Error("createSimpleRenderer: logoPath must stay on the baseUrl origin");
97
+ }
98
+ return resolved.href;
99
+ }
100
+
28
101
  function resolvePrimaryColor(branding: MailBranding | undefined): string {
29
102
  const color = branding?.primaryColor;
30
103
  return color && HEX_COLOR_PATTERN.test(color) ? color : DEFAULT_PRIMARY_COLOR;
31
104
  }
32
105
 
33
- function renderBrandingHeader(branding: MailBranding | undefined, primaryColor: string): string {
106
+ function renderBrandingHeader(
107
+ branding: MailBranding | undefined,
108
+ primaryColor: string,
109
+ logoUrl: string | undefined,
110
+ ): string {
34
111
  if (!branding) return "";
35
- const logoUrl = branding.logoUrl && isHttpUrl(branding.logoUrl) ? branding.logoUrl : undefined;
36
112
  if (!logoUrl && !branding.productName) return "";
37
113
  const logo = logoUrl
38
114
  ? `<img src="${escapeHtmlAttr(logoUrl)}" alt="${escapeHtmlAttr(branding.productName ?? "")}" style="display:block;max-height:40px;max-width:200px;border:0" />`
@@ -40,18 +116,24 @@ function renderBrandingHeader(branding: MailBranding | undefined, primaryColor:
40
116
  return `<div style="margin:0 0 24px;padding:0 0 16px;border-bottom:3px solid ${escapeHtmlAttr(primaryColor)}">${logo}</div>`;
41
117
  }
42
118
 
43
- function renderBrandingFooter(branding: MailBranding | undefined): string {
119
+ function renderBrandingFooter(
120
+ branding: MailBranding | undefined,
121
+ locale: string | undefined,
122
+ ): string {
44
123
  if (!branding) return "";
45
- const links = (branding.footerLinks ?? [])
46
- .filter((link) => isHttpUrl(link.url))
47
- .map(
48
- (link) =>
49
- `<a href="${escapeHtmlAttr(link.url)}" style="color:#999">${escapeHtml(link.label)}</a>`,
50
- );
51
- const footerPartsHtml = [
52
- branding.footerText ? escapeHtml(branding.footerText) : "",
53
- links.join(" · "),
54
- ].filter((part) => part !== "");
124
+ const pick = (value: LocalizedText | undefined): string | undefined =>
125
+ resolveLocalized(value, locale, branding.defaultLocale);
126
+ const links = (branding.footerLinks ?? []).flatMap((link) => {
127
+ const url = pick(link.url);
128
+ if (url === undefined || !isHttpUrl(url)) return [];
129
+ return [
130
+ `<a href="${escapeHtmlAttr(url)}" style="color:#999">${escapeHtml(pick(link.label) ?? "")}</a>`,
131
+ ];
132
+ });
133
+ const footerText = pick(branding.footerText);
134
+ const footerPartsHtml = [footerText ? escapeHtml(footerText) : "", links.join(" · ")].filter(
135
+ (part) => part !== "",
136
+ );
55
137
  if (footerPartsHtml.length === 0) return "";
56
138
  const footerHtml = footerPartsHtml.join("<br />");
57
139
  return `<p style="margin:16px 0 0;color:#999;font-size:12px">${footerHtml}</p>`;
@@ -85,6 +167,8 @@ function renderSection(section: Section, primaryColor: string): string {
85
167
  // No external dependencies, no template engine — just string concatenation.
86
168
  export function createSimpleRenderer(branding?: MailBranding): NotificationRenderer {
87
169
  const primaryColor = resolvePrimaryColor(branding);
170
+ const logoUrl = resolveLogoUrl(branding);
171
+ if (branding) assertLocalizedUrlsAreHttp(branding);
88
172
  return {
89
173
  name: "simple",
90
174
 
@@ -98,7 +182,7 @@ export function createSimpleRenderer(branding?: MailBranding): NotificationRende
98
182
  const parts: string[] = [];
99
183
  parts.push('<!DOCTYPE html><html><body style="margin:0;padding:0;font-family:sans-serif">');
100
184
  parts.push('<div style="max-width:600px;margin:0 auto;padding:24px">');
101
- parts.push(renderBrandingHeader(branding, primaryColor));
185
+ parts.push(renderBrandingHeader(branding, primaryColor, logoUrl));
102
186
 
103
187
  if (header) {
104
188
  parts.push(
@@ -118,7 +202,7 @@ export function createSimpleRenderer(branding?: MailBranding): NotificationRende
118
202
  );
119
203
  }
120
204
 
121
- parts.push(renderBrandingFooter(branding));
205
+ parts.push(renderBrandingFooter(branding, input.locale));
122
206
  parts.push("</div></body></html>");
123
207
  return parts.join("");
124
208
  },
@@ -0,0 +1,30 @@
1
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
2
+ import {
3
+ CACHE_SYNC_TOPICS,
4
+ type CacheSyncBus,
5
+ type TenantConfigSyncMessage,
6
+ } from "@cosmicdrift/kumiko-framework/redis";
7
+
8
+ type AnnouncingContext = {
9
+ readonly cacheSync?: CacheSyncBus;
10
+ readonly scheduleAfterCommit?: (hook: () => Promise<void>) => void;
11
+ };
12
+
13
+ // Secret writes bypass config:write:*, yet provider caches (e.g. the S3 secret
14
+ // key) depend on them: announce on the tenant-config topic, after commit so a
15
+ // replica reloading on the message sees the new value.
16
+ export function announceSecretChange(
17
+ ctx: AnnouncingContext,
18
+ tenantId: TenantId,
19
+ key: string,
20
+ ): void {
21
+ const bus = ctx.cacheSync;
22
+ // skip: no bus wired (single-process setup), nothing to announce
23
+ if (!bus) return;
24
+ const message: TenantConfigSyncMessage = { tenantId, key };
25
+ const publish = async (): Promise<void> => {
26
+ bus.publish(CACHE_SYNC_TOPICS.tenantConfig, message);
27
+ };
28
+ if (ctx.scheduleAfterCommit) ctx.scheduleAfterCommit(publish);
29
+ else bus.publish(CACHE_SYNC_TOPICS.tenantConfig, message);
30
+ }
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.341.0",
4
+ "type": "improvement",
5
+ "title": "Chat channel secret values are validated on write",
6
+ "detail": "`r.secret` and `r.secretNamespace` accept a `valueSchema`. `secrets:write:set` checks the value against it after the key and role checks and answers a failure with a 400 validation error on field `value` (`secrets.errors.invalidValue`); the response never contains the value or the schema's issues. `secrets:write:delete` is unaffected.\nThe chat channels use it: Slack, Discord and Teams webhook secrets must be URLs that pass the channel's host allowlist (including the Discord `/api/webhooks/` path), and the Telegram bot token must have the `<bot id>:<secret>` shape. The allowlist comes from the same options as the channel, so `createChannelSlackFeature({ allowedHosts })` also governs what can be stored.\n`channel-slack`, `channel-discord`, `channel-teams` and `channel-telegram` now export their secret keys (`SLACK_SECRET_KEYS`, `DISCORD_SECRET_KEYS`, `TEAMS_SECRET_KEYS`, `TELEGRAM_SECRET_KEYS`), allowlist constants and `isTelegramChatId` / `isTelegramBotToken`. `delivery` exports `checkChatWebhookTarget`, `chatWebhookUrlSchema` and `resolveChatWebhookTarget`, so apps can validate an address or URL when a user creates a channel.\nExisting invalid secrets stay stored; only new writes are checked. Writes through `ctx.secrets.set` in feature code run through the same check."
7
+ },
8
+ {
9
+ "version": "0.341.0",
10
+ "type": "breaking",
11
+ "title": "ctx.secrets.set validates against the declared valueSchema",
12
+ "migration": "Code that calls createSecretsContext({ db, masterKeyProvider }) must also pass registry (the booted Registry). Programmatic ctx.secrets.set calls with a value that fails the key's valueSchema now throw a ValidationError instead of storing it."
13
+ },
2
14
  {
3
15
  "version": "0.337.0",
4
16
  "type": "breaking",
@@ -76,7 +76,7 @@ export function requireSecretsContext(
76
76
  throw new InternalError({
77
77
  message:
78
78
  `[${handlerName}] ctx.secrets missing — pass ` +
79
- "createSecretsContext({db, masterKeyProvider}) via extraContext.secrets at boot.",
79
+ "createSecretsContext({db, masterKeyProvider, registry}) via extraContext.secrets at boot.",
80
80
  });
81
81
  }
82
82
  const raw = ctx.secrets;
@@ -1,6 +1,7 @@
1
1
  import { type AccessRule, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { failNotFound } from "@cosmicdrift/kumiko-framework/errors";
3
3
  import * as z from "zod";
4
+ import { announceSecretChange } from "../announce-secret-change.js";
4
5
  import { DEFAULT_SECRETS_ACCESS } from "../constants.js";
5
6
  import { requireSecretsContext } from "../feature.js";
6
7
  import { checkSecretKeyWrite } from "../write-gate.js";
@@ -23,6 +24,7 @@ export function createDeleteHandler(access: AccessRule = DEFAULT_SECRETS_ACCESS)
23
24
  deletedBy: event.user.id,
24
25
  });
25
26
  if (!removed) return failNotFound("tenant-secret", event.payload.key);
27
+ announceSecretChange(ctx, event.user.tenantId, event.payload.key);
26
28
  return { isSuccess: true, data: { key: event.payload.key } };
27
29
  },
28
30
  });