@cosmicdrift/kumiko-samples 0.339.0 → 0.341.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 (115) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +6 -1
  3. package/packages/bundled-features/src/auth-email-password/bootstrap.ts +309 -0
  4. package/packages/bundled-features/src/auth-email-password/changes.json +18 -0
  5. package/packages/bundled-features/src/auth-email-password/constants.ts +5 -0
  6. package/packages/bundled-features/src/auth-email-password/errors.ts +9 -0
  7. package/packages/bundled-features/src/auth-email-password/feature.ts +2 -0
  8. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +13 -1
  9. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +12 -0
  10. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +210 -113
  11. package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +6 -1
  12. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +1 -0
  13. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +1 -0
  14. package/packages/bundled-features/src/auth-email-password/index.ts +13 -0
  15. package/packages/bundled-features/src/auth-email-password/invite-token-store.ts +6 -0
  16. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +1 -0
  17. package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
  18. package/packages/bundled-features/src/auth-mfa/config.ts +12 -3
  19. package/packages/bundled-features/src/auth-mfa/feature.ts +7 -2
  20. package/packages/bundled-features/src/auth-mfa/mfa-status-checker.ts +2 -1
  21. package/packages/bundled-features/src/billing-foundation/changes.json +13 -0
  22. package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +1 -0
  23. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +1 -0
  24. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-notify.ts +1 -0
  25. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +83 -34
  26. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +2 -0
  27. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +4 -1
  28. package/packages/bundled-features/src/billing-foundation/projection.ts +35 -41
  29. package/packages/bundled-features/src/channel-discord/discord-channel.ts +5 -0
  30. package/packages/bundled-features/src/channel-discord/feature.ts +16 -1
  31. package/packages/bundled-features/src/channel-discord/index.ts +7 -1
  32. package/packages/bundled-features/src/channel-email/email-channel.ts +1 -0
  33. package/packages/bundled-features/src/channel-slack/feature.ts +12 -1
  34. package/packages/bundled-features/src/channel-slack/index.ts +6 -1
  35. package/packages/bundled-features/src/channel-slack/slack-channel.ts +5 -0
  36. package/packages/bundled-features/src/channel-teams/feature.ts +12 -1
  37. package/packages/bundled-features/src/channel-teams/index.ts +6 -1
  38. package/packages/bundled-features/src/channel-teams/teams-channel.ts +5 -0
  39. package/packages/bundled-features/src/channel-telegram/changes.json +9 -0
  40. package/packages/bundled-features/src/channel-telegram/feature.ts +8 -2
  41. package/packages/bundled-features/src/channel-telegram/index.ts +8 -1
  42. package/packages/bundled-features/src/channel-telegram/telegram-channel.ts +39 -11
  43. package/packages/bundled-features/src/delivery/changes.json +7 -0
  44. package/packages/bundled-features/src/delivery/chat-webhook-channel.ts +18 -5
  45. package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +19 -10
  46. package/packages/bundled-features/src/delivery/delivery-service.ts +78 -32
  47. package/packages/bundled-features/src/delivery/index.ts +4 -0
  48. package/packages/bundled-features/src/delivery/jobs.ts +3 -1
  49. package/packages/bundled-features/src/delivery/types.ts +10 -4
  50. package/packages/bundled-features/src/files/changes.json +13 -1
  51. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +7 -0
  52. package/packages/bundled-features/src/inbound-mail-foundation/projection.ts +57 -58
  53. package/packages/bundled-features/src/legal-pages/README.md +26 -3
  54. package/packages/bundled-features/src/legal-pages/feature.ts +27 -85
  55. package/packages/bundled-features/src/legal-pages/lib/boot-check.ts +57 -0
  56. package/packages/bundled-features/src/legal-pages/lib/validate-routes.ts +21 -0
  57. package/packages/bundled-features/src/page-render/index.ts +2 -0
  58. package/packages/bundled-features/src/page-render/layout.ts +17 -0
  59. package/packages/bundled-features/src/page-render/trailing-slash.ts +15 -0
  60. package/packages/bundled-features/src/renderer-simple/changes.json +6 -0
  61. package/packages/bundled-features/src/renderer-simple/feature.ts +1 -0
  62. package/packages/bundled-features/src/renderer-simple/index.ts +6 -1
  63. package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +103 -19
  64. package/packages/bundled-features/src/secrets/announce-secret-change.ts +30 -0
  65. package/packages/bundled-features/src/secrets/changes.json +12 -0
  66. package/packages/bundled-features/src/secrets/feature.ts +1 -1
  67. package/packages/bundled-features/src/secrets/handlers/delete.write.ts +2 -0
  68. package/packages/bundled-features/src/secrets/handlers/set.write.ts +5 -1
  69. package/packages/bundled-features/src/secrets/i18n.ts +4 -0
  70. package/packages/bundled-features/src/secrets/secrets-context.ts +9 -0
  71. package/packages/bundled-features/src/secrets/write-gate.ts +41 -0
  72. package/packages/bundled-features/src/tenant/changes.json +6 -0
  73. package/packages/bundled-features/src/tenant/constants.ts +1 -0
  74. package/packages/bundled-features/src/tenant/handlers/add-member.write.ts +27 -3
  75. package/packages/bundled-features/src/tenant/handlers/invitations.query.ts +15 -1
  76. package/packages/bundled-features/src/tenant/index.ts +1 -0
  77. package/packages/bundled-features/src/tenant/invitation-table.ts +10 -0
  78. package/packages/bundled-features/src/tenant-lifecycle/lifecycle-gate.ts +2 -0
  79. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +2 -2
  80. package/packages/bundled-features/src/tier-engine/feature.ts +92 -14
  81. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +18 -13
  82. package/packages/bundled-features/src/user/changes.json +13 -6
  83. package/packages/bundled-features/src/user/handlers/update-roles.ts +7 -23
  84. package/packages/bundled-features/src/user/invited-global-roles.ts +58 -0
  85. package/packages/bundled-features/src/user-data-rights/feature.ts +3 -0
  86. package/packages/bundled-features/src/waitlist/changes.json +17 -0
  87. package/packages/bundled-features/src/waitlist/constants.ts +44 -0
  88. package/packages/bundled-features/src/waitlist/entity.ts +76 -0
  89. package/packages/bundled-features/src/waitlist/feature.ts +50 -0
  90. package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +175 -0
  91. package/packages/bundled-features/src/waitlist/handlers/list.query.ts +12 -0
  92. package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +120 -0
  93. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +163 -0
  94. package/packages/bundled-features/src/waitlist/i18n.ts +33 -0
  95. package/packages/bundled-features/src/waitlist/index.ts +15 -0
  96. package/packages/bundled-features/src/waitlist/lib.ts +36 -0
  97. package/packages/bundled-features/src/waitlist/mail.ts +114 -0
  98. package/packages/bundled-features/src/waitlist/options.ts +41 -0
  99. package/packages/bundled-features/src/waitlist/payloads.ts +55 -0
  100. package/packages/bundled-features/src/waitlist/screens.ts +43 -0
  101. package/packages/bundled-features/src/waitlist/user-data-hooks.ts +82 -0
  102. package/packages/bundled-features/src/workflow-runner/changes.json +7 -0
  103. package/packages/bundled-features/src/workflow-runner/db/queries/due-runs.ts +10 -13
  104. package/packages/bundled-features/src/workflow-runner/feature.ts +4 -11
  105. package/samples/apps/use-all-bundled/feature-manifest.json +36 -5
  106. package/samples/apps/use-all-bundled/kumiko/migrations/0033_waitlist.rebuild.json +4 -0
  107. package/samples/apps/use-all-bundled/schema/generate.ts +5 -0
  108. package/samples/apps/use-all-bundled/schema/schema.generated.ts +3 -0
  109. package/samples/apps/use-all-bundled/src/run-config.ts +2 -0
  110. package/samples/recipes/chat-channels/README.md +2 -0
  111. package/samples/recipes/managed-config/README.md +1 -1
  112. package/samples/recipes/rate-limiting/README.md +21 -0
  113. package/packages/bundled-features/src/billing-foundation/db/queries/payment-projection.ts +0 -19
  114. package/packages/bundled-features/src/billing-foundation/db/queries/subscription-projection.ts +0 -15
  115. package/packages/bundled-features/src/inbound-mail-foundation/db/queries/inbound-projections.ts +0 -38
@@ -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
+ ]
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.340.0",
4
+ "type": "improvement",
5
+ "title": "Inbound mail projections use typed bun-db helpers",
6
+ "detail": "Mail-account and mail-thread events upsert through `upsertOnConflict` (`connectedAt` is only written on insert), inbound messages insert through `insertOnConflictDoNothing`. The raw `db/queries/inbound-projections.ts` module is gone.",
7
+ "migration": "keine"
8
+ },
2
9
  {
3
10
  "version": "0.339.0",
4
11
  "type": "improvement",
@@ -17,12 +17,13 @@
17
17
  // write-handler. Die applies kopieren nur durch; decrypted wird erst
18
18
  // in den list-queries.
19
19
 
20
- import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
21
- import { defineApply } from "@cosmicdrift/kumiko-framework/engine";
22
20
  import {
23
- insertIgnoreProjectionRow,
24
- upsertProjectionRow,
25
- } from "./db/queries/inbound-projections.js";
21
+ insertOnConflictDoNothing,
22
+ requireEntityTableMeta,
23
+ upsertOnConflict,
24
+ } from "@cosmicdrift/kumiko-framework/bun-db";
25
+ import { buildEntityTable, type EntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
26
+ import { defineApply } from "@cosmicdrift/kumiko-framework/engine";
26
27
  import { inboundMessageEntity, mailAccountEntity, mailThreadEntity } from "./entities.js";
27
28
  import type {
28
29
  InboundMessageEventPayload,
@@ -39,9 +40,20 @@ export const inboundMessagesProjectionTable = buildEntityTable(
39
40
  );
40
41
  export const mailThreadsProjectionTable = buildEntityTable("mail-thread", mailThreadEntity);
41
42
 
42
- function tableNameOf(table: unknown): string {
43
- return (table as { tableName: string }).tableName;
44
- }
43
+ // Unbranded metas: the apply functions below ARE the executor of these
44
+ // projections, so they may write through the typed helpers.
45
+ const mailAccountsProjectionMeta: EntityTableMeta = requireEntityTableMeta(
46
+ mailAccountsProjectionTable,
47
+ "mail-account",
48
+ );
49
+ const inboundMessagesProjectionMeta: EntityTableMeta = requireEntityTableMeta(
50
+ inboundMessagesProjectionTable,
51
+ "inbound-message",
52
+ );
53
+ const mailThreadsProjectionMeta: EntityTableMeta = requireEntityTableMeta(
54
+ mailThreadsProjectionTable,
55
+ "mail-thread",
56
+ );
45
57
 
46
58
  // =============================================================================
47
59
  // mail-account — connected/updated/disconnected teilen den payload-shape,
@@ -49,40 +61,30 @@ function tableNameOf(table: unknown): string {
49
61
  // defensive consistency (rebuild-aus-dem-Nichts, out-of-order).
50
62
  // =============================================================================
51
63
 
52
- const ACCOUNT_SET_CLAUSES = [
53
- `"provider" = EXCLUDED."provider"`,
54
- `"auth_method" = EXCLUDED."auth_method"`,
55
- `"owner_user_id" = EXCLUDED."owner_user_id"`,
56
- `"display_name" = EXCLUDED."display_name"`,
57
- `"address" = EXCLUDED."address"`,
58
- `"status" = EXCLUDED."status"`,
59
- `"watch_state" = EXCLUDED."watch_state"`,
60
- // connected_at bewusst NICHT im SET: der Erst-Connect-Zeitpunkt
61
- // bleibt stehen, updated/disconnected überschreiben ihn nicht.
62
- ];
63
-
64
64
  const applyMailAccountUpsert = defineApply<MailAccountEventPayload>(async (event, tx) => {
65
65
  const p = event.payload;
66
- const insertCols = {
67
- id: event.aggregateId,
68
- tenant_id: event.tenantId,
66
+ const mutable = {
69
67
  provider: p.provider,
70
- auth_method: p.authMethod,
71
- owner_user_id: p.ownerUserId,
72
- display_name: p.displayName,
68
+ authMethod: p.authMethod,
69
+ ownerUserId: p.ownerUserId,
70
+ displayName: p.displayName,
73
71
  address: p.address,
74
72
  status: p.status,
75
- watch_state: p.watchState,
76
- // Insert-Pfad = Erst-Connect (bzw. Rebuild: createdAt des ersten
77
- // events des Streams — fachlich derselbe Zeitpunkt).
78
- connected_at: event.createdAt.toString(),
73
+ watchState: p.watchState,
79
74
  };
80
- await upsertProjectionRow(
75
+ await upsertOnConflict(
81
76
  tx,
82
- tableNameOf(mailAccountsProjectionTable),
83
- insertCols,
84
- ACCOUNT_SET_CLAUSES,
85
- Object.values(insertCols),
77
+ mailAccountsProjectionMeta,
78
+ {
79
+ id: event.aggregateId,
80
+ tenantId: event.tenantId,
81
+ ...mutable,
82
+ // Only the insert path is the first connect (on rebuild: createdAt of the
83
+ // stream's first event, the same moment). Left out of `update` so the
84
+ // first-connect time survives updated/disconnected events.
85
+ connectedAt: event.createdAt,
86
+ },
87
+ { conflictKeys: ["id"], update: mutable },
86
88
  );
87
89
  });
88
90
 
@@ -104,20 +106,20 @@ export const applyMailAccountDisconnected = applyMailAccountUpsert;
104
106
  export const applyInboundMessageReceived = defineApply<InboundMessageEventPayload>(
105
107
  async (event, tx) => {
106
108
  const p = event.payload;
107
- await insertIgnoreProjectionRow(tx, tableNameOf(inboundMessagesProjectionTable), {
109
+ await insertOnConflictDoNothing(tx, inboundMessagesProjectionMeta, {
108
110
  id: event.aggregateId,
109
- tenant_id: event.tenantId,
110
- account_id: p.accountId,
111
- owner_user_id: p.ownerUserId,
112
- message_id_header: p.messageIdHeader,
113
- thread_key: p.threadKey,
111
+ tenantId: event.tenantId,
112
+ accountId: p.accountId,
113
+ ownerUserId: p.ownerUserId,
114
+ messageIdHeader: p.messageIdHeader,
115
+ threadKey: p.threadKey,
114
116
  from: p.from,
115
117
  to: p.to,
116
118
  cc: p.cc,
117
119
  subject: p.subject,
118
120
  snippet: p.snippet,
119
- received_at: p.receivedAtIso,
120
- body_ref: p.bodyRef,
121
+ receivedAt: p.receivedAtIso,
122
+ bodyRef: p.bodyRef,
121
123
  scope: p.scope,
122
124
  });
123
125
  },
@@ -133,23 +135,20 @@ export const applyInboundMessageReceived = defineApply<InboundMessageEventPayloa
133
135
  /** mail-thread-updated → UPSERT full mit dem Payload-Snapshot. */
134
136
  export const applyMailThreadUpdated = defineApply<MailThreadEventPayload>(async (event, tx) => {
135
137
  const p = event.payload;
136
- const insertCols = {
137
- id: event.aggregateId,
138
- tenant_id: event.tenantId,
139
- thread_key: p.threadKey,
138
+ const mutable = {
140
139
  subject: p.subject,
141
- last_message_at: p.lastMessageAtIso,
142
- message_count: p.messageCount,
140
+ lastMessageAt: p.lastMessageAtIso,
141
+ messageCount: p.messageCount,
143
142
  };
144
- await upsertProjectionRow(
143
+ await upsertOnConflict(
145
144
  tx,
146
- tableNameOf(mailThreadsProjectionTable),
147
- insertCols,
148
- [
149
- `"subject" = EXCLUDED."subject"`,
150
- `"last_message_at" = EXCLUDED."last_message_at"`,
151
- `"message_count" = EXCLUDED."message_count"`,
152
- ],
153
- Object.values(insertCols),
145
+ mailThreadsProjectionMeta,
146
+ {
147
+ id: event.aggregateId,
148
+ tenantId: event.tenantId,
149
+ threadKey: p.threadKey,
150
+ ...mutable,
151
+ },
152
+ { conflictKeys: ["id"], update: mutable },
154
153
  );
155
154
  });
@@ -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";