@cosmicdrift/kumiko-bundled-features 0.340.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 (151) hide show
  1. package/dist/auth-email-password/bootstrap.d.ts +74 -0
  2. package/dist/auth-email-password/bootstrap.js +165 -0
  3. package/dist/auth-email-password/constants.d.ts +2 -0
  4. package/dist/auth-email-password/constants.js +5 -0
  5. package/dist/auth-email-password/errors.d.ts +1 -0
  6. package/dist/auth-email-password/errors.js +6 -0
  7. package/dist/auth-email-password/feature.js +2 -1
  8. package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +9 -1
  9. package/dist/auth-email-password/handlers/invite-accept.write.js +8 -0
  10. package/dist/auth-email-password/handlers/invite-create.write.d.ts +11 -0
  11. package/dist/auth-email-password/handlers/invite-create.write.js +134 -87
  12. package/dist/auth-email-password/handlers/invite-signup-complete.write.js +5 -1
  13. package/dist/auth-email-password/handlers/signup-request.write.js +1 -0
  14. package/dist/auth-email-password/handlers/token-request-handler.js +1 -0
  15. package/dist/auth-email-password/index.d.ts +2 -0
  16. package/dist/auth-email-password/index.js +1 -0
  17. package/dist/auth-email-password/invite-token-store.d.ts +3 -0
  18. package/dist/auth-email-password/invite-token-store.js +5 -0
  19. package/dist/auth-email-password/magic-link-mail.js +1 -0
  20. package/dist/auth-mfa/config.d.ts +1 -1
  21. package/dist/auth-mfa/config.js +8 -3
  22. package/dist/auth-mfa/feature.d.ts +2 -0
  23. package/dist/auth-mfa/feature.js +2 -1
  24. package/dist/auth-mfa/mfa-status-checker.d.ts +2 -0
  25. package/dist/auth-mfa/mfa-status-checker.js +1 -1
  26. package/dist/billing-foundation/consumer-protection/issue-confirmation.js +1 -0
  27. package/dist/billing-foundation/consumer-protection/termination-mail.js +1 -0
  28. package/dist/billing-foundation/consumer-protection/termination-notify.js +1 -0
  29. package/dist/billing-foundation/consumer-protection/termination-pages.d.ts +8 -1
  30. package/dist/billing-foundation/consumer-protection/termination-pages.js +59 -32
  31. package/dist/billing-foundation/consumer-protection/termination-request.js +2 -0
  32. package/dist/billing-foundation/consumer-protection/termination-texts.d.ts +1 -0
  33. package/dist/billing-foundation/consumer-protection/termination-texts.js +3 -1
  34. package/dist/channel-discord/discord-channel.d.ts +4 -0
  35. package/dist/channel-discord/discord-channel.js +4 -0
  36. package/dist/channel-discord/feature.js +7 -2
  37. package/dist/channel-discord/index.d.ts +1 -1
  38. package/dist/channel-discord/index.js +1 -1
  39. package/dist/channel-email/email-channel.js +1 -0
  40. package/dist/channel-slack/feature.js +4 -2
  41. package/dist/channel-slack/index.d.ts +1 -1
  42. package/dist/channel-slack/index.js +1 -1
  43. package/dist/channel-slack/slack-channel.d.ts +4 -0
  44. package/dist/channel-slack/slack-channel.js +4 -0
  45. package/dist/channel-teams/feature.js +4 -2
  46. package/dist/channel-teams/index.d.ts +1 -1
  47. package/dist/channel-teams/index.js +1 -1
  48. package/dist/channel-teams/teams-channel.d.ts +4 -0
  49. package/dist/channel-teams/teams-channel.js +4 -0
  50. package/dist/channel-telegram/feature.js +4 -2
  51. package/dist/channel-telegram/index.d.ts +1 -1
  52. package/dist/channel-telegram/index.js +1 -1
  53. package/dist/channel-telegram/telegram-channel.d.ts +8 -0
  54. package/dist/channel-telegram/telegram-channel.js +28 -12
  55. package/dist/delivery/chat-webhook-channel.d.ts +5 -1
  56. package/dist/delivery/chat-webhook-channel.js +10 -5
  57. package/dist/delivery/chat-webhook-sender.d.ts +6 -2
  58. package/dist/delivery/chat-webhook-sender.js +13 -8
  59. package/dist/delivery/delivery-service.d.ts +4 -3
  60. package/dist/delivery/delivery-service.js +53 -33
  61. package/dist/delivery/index.d.ts +2 -2
  62. package/dist/delivery/index.js +2 -2
  63. package/dist/delivery/jobs.js +3 -1
  64. package/dist/delivery/types.d.ts +5 -3
  65. package/dist/legal-pages/feature.d.ts +3 -15
  66. package/dist/legal-pages/feature.js +20 -50
  67. package/dist/legal-pages/lib/boot-check.d.ts +10 -0
  68. package/dist/legal-pages/lib/boot-check.js +33 -0
  69. package/dist/legal-pages/lib/validate-routes.d.ts +2 -0
  70. package/dist/legal-pages/lib/validate-routes.js +17 -0
  71. package/dist/page-render/index.d.ts +2 -1
  72. package/dist/page-render/index.js +1 -0
  73. package/dist/page-render/layout.d.ts +7 -0
  74. package/dist/page-render/layout.js +5 -0
  75. package/dist/page-render/trailing-slash.d.ts +2 -0
  76. package/dist/page-render/trailing-slash.js +15 -0
  77. package/dist/renderer-simple/feature.js +1 -0
  78. package/dist/renderer-simple/index.d.ts +1 -1
  79. package/dist/renderer-simple/index.js +1 -1
  80. package/dist/renderer-simple/simple-renderer.d.ts +12 -5
  81. package/dist/renderer-simple/simple-renderer.js +66 -12
  82. package/dist/secrets/announce-secret-change.d.ts +8 -0
  83. package/dist/secrets/announce-secret-change.js +18 -0
  84. package/dist/secrets/feature.js +1 -1
  85. package/dist/secrets/handlers/delete.write.js +2 -0
  86. package/dist/secrets/handlers/set.write.js +6 -1
  87. package/dist/secrets/i18n.d.ts +4 -0
  88. package/dist/secrets/i18n.js +4 -0
  89. package/dist/secrets/secrets-context.d.ts +2 -0
  90. package/dist/secrets/secrets-context.js +3 -0
  91. package/dist/secrets/write-gate.d.ts +5 -1
  92. package/dist/secrets/write-gate.js +24 -1
  93. package/dist/tenant/constants.d.ts +1 -0
  94. package/dist/tenant/constants.js +1 -0
  95. package/dist/tenant/handlers/add-member.write.js +14 -3
  96. package/dist/tenant/handlers/invitations.query.d.ts +8 -0
  97. package/dist/tenant/handlers/invitations.query.js +15 -1
  98. package/dist/tenant/index.d.ts +1 -0
  99. package/dist/tenant/index.js +1 -0
  100. package/dist/tenant/invitation-table.d.ts +2 -0
  101. package/dist/tenant/invitation-table.js +10 -1
  102. package/dist/tenant-lifecycle/lifecycle-gate.d.ts +1 -0
  103. package/dist/tenant-lifecycle/lifecycle-gate.js +2 -0
  104. package/dist/tenant-lifecycle/run-tenant-destroy.js +2 -2
  105. package/dist/tier-engine/feature.js +83 -14
  106. package/dist/tier-engine/handlers/set-tenant-tier.write.d.ts +3 -3
  107. package/dist/tier-engine/handlers/set-tenant-tier.write.js +17 -10
  108. package/dist/user/invited-global-roles.d.ts +14 -0
  109. package/dist/user/invited-global-roles.js +35 -0
  110. package/dist/user-data-rights/feature.js +3 -0
  111. package/dist/waitlist/constants.d.ts +33 -0
  112. package/dist/waitlist/constants.js +35 -0
  113. package/dist/waitlist/entity.d.ts +433 -0
  114. package/dist/waitlist/entity.js +64 -0
  115. package/dist/waitlist/feature.d.ts +3 -0
  116. package/dist/waitlist/feature.js +38 -0
  117. package/dist/waitlist/handlers/invite.write.d.ts +9 -0
  118. package/dist/waitlist/handlers/invite.write.js +119 -0
  119. package/dist/waitlist/handlers/list.query.d.ts +1 -0
  120. package/dist/waitlist/handlers/list.query.js +10 -0
  121. package/dist/waitlist/handlers/reject.write.d.ts +7 -0
  122. package/dist/waitlist/handlers/reject.write.js +80 -0
  123. package/dist/waitlist/handlers/submit.write.d.ts +13 -0
  124. package/dist/waitlist/handlers/submit.write.js +112 -0
  125. package/dist/waitlist/i18n.d.ts +6 -0
  126. package/dist/waitlist/i18n.js +31 -0
  127. package/dist/waitlist/index.d.ts +5 -0
  128. package/dist/waitlist/index.js +4 -0
  129. package/dist/waitlist/lib.d.ts +10 -0
  130. package/dist/waitlist/lib.js +24 -0
  131. package/dist/waitlist/mail.d.ts +15 -0
  132. package/dist/waitlist/mail.js +73 -0
  133. package/dist/waitlist/options.d.ts +29 -0
  134. package/dist/waitlist/options.js +8 -0
  135. package/dist/waitlist/payloads.d.ts +14 -0
  136. package/dist/waitlist/payloads.js +47 -0
  137. package/dist/waitlist/screens.d.ts +2 -0
  138. package/dist/waitlist/screens.js +42 -0
  139. package/dist/waitlist/user-data-hooks.d.ts +3 -0
  140. package/dist/waitlist/user-data-hooks.js +71 -0
  141. package/package.json +17 -9
  142. package/src/auth-email-password/changes.json +18 -0
  143. package/src/auth-mfa/changes.json +6 -0
  144. package/src/billing-foundation/changes.json +6 -0
  145. package/src/channel-telegram/changes.json +9 -0
  146. package/src/delivery/changes.json +7 -0
  147. package/src/files/changes.json +13 -1
  148. package/src/renderer-simple/changes.json +6 -0
  149. package/src/secrets/changes.json +12 -0
  150. package/src/tenant/changes.json +6 -0
  151. package/src/waitlist/changes.json +17 -0
@@ -1,6 +1,6 @@
1
1
  import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
2
2
  import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
3
- import type { NotifyOptions, NotifyPriority, Registry, SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
+ import type { NotifyDeliveryStatus, NotifyJobDispatcher, NotifyOptions, NotifyPriority, NotifyResult, Registry, SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
5
5
  import { DELIVERY_CHANNEL_EXTENSION } from "./constants.js";
6
6
  export type ChannelContext = {
@@ -15,6 +15,7 @@ export type ChannelMessage = {
15
15
  readonly title: string;
16
16
  readonly body: string | undefined;
17
17
  readonly data: Readonly<Record<string, unknown>> | undefined;
18
+ readonly locale?: string | undefined;
18
19
  };
19
20
  export type ChannelResult = {
20
21
  readonly status: "sent" | "failed" | "skipped";
@@ -37,6 +38,7 @@ export type DeliveryChannel = {
37
38
  export type RendererInput = {
38
39
  readonly template: string;
39
40
  readonly variables: Readonly<Record<string, unknown>>;
41
+ readonly locale?: string | undefined;
40
42
  };
41
43
  export type NotificationRenderer = {
42
44
  readonly name: string;
@@ -48,12 +50,12 @@ export type DeliveryLogEntry = {
48
50
  readonly channel: string;
49
51
  readonly recipientId: string | null;
50
52
  readonly recipientAddress: string | null;
51
- readonly status: "queued" | "sent" | "failed" | "skipped";
53
+ readonly status: NotifyDeliveryStatus;
52
54
  readonly error: string | null;
53
55
  readonly priority: NotifyPriority;
54
56
  };
55
57
  export type DeliveryService = {
56
- notify(notificationType: string, options: NotifyOptions, user: SessionUser, tenantId: TenantId): Promise<void>;
58
+ notify(notificationType: string, options: NotifyOptions, user: SessionUser, tenantId: TenantId, jobDispatcher?: NotifyJobDispatcher): Promise<NotifyResult>;
57
59
  };
58
60
  export type DeliveryChannelPlugin = Omit<DeliveryChannel, "name">;
59
61
  export declare function isDeliveryChannelPlugin(o: unknown): o is DeliveryChannelPlugin;
@@ -1,12 +1,8 @@
1
- import { type TemplateResolverApi } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
2
1
  import { type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { type PublicPageWrapLayout } from "../page-render/index.js";
3
3
  import { type LegalPageRoute, type LegalRequiredBlock } from "./constants.js";
4
- export type LegalPagesWrapLayout = (opts: {
5
- readonly title: string;
6
- readonly bodyHtml: string;
7
- readonly lang: string;
8
- readonly slug?: string;
9
- }) => string;
4
+ export { type LegalPagesBootCheckCtx, runLegalPagesBootCheck } from "./lib/boot-check.js";
5
+ export type LegalPagesWrapLayout = PublicPageWrapLayout;
10
6
  export type LegalPagesOptions = {
11
7
  /** Custom Layout-Wrapper für die /legal/*-Routes. Default: minimaler
12
8
  * HTML-Skeleton aus markdown.ts (`wrapInLayout`). Apps die ihr eigenes
@@ -27,11 +23,3 @@ export type LegalPagesOptions = {
27
23
  readonly requiredBlocks?: readonly LegalRequiredBlock[];
28
24
  };
29
25
  export declare function createLegalPagesFeature(opts?: LegalPagesOptions): FeatureDefinition;
30
- export type LegalPagesBootCheckCtx = {
31
- readonly templateResolver?: TemplateResolverApi;
32
- readonly log?: {
33
- readonly info?: (msg: string) => void;
34
- readonly warn?: (msg: string) => void;
35
- };
36
- };
37
- export declare function runLegalPagesBootCheck(ctx: LegalPagesBootCheckCtx, requiredBlocks?: readonly LegalRequiredBlock[]): Promise<void>;
@@ -1,9 +1,11 @@
1
- import { requireTemplateResolver, TEXT_BLOCK_KIND, } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
2
1
  import { computeRevisionEtag, etagMatches } from "@cosmicdrift/kumiko-framework/api";
3
2
  import { defineFeature, SYSTEM_TENANT_ID, } from "@cosmicdrift/kumiko-framework/engine";
4
- import { cachedSecurePageResponse } from "../page-render/index.js";
3
+ import { cachedSecurePageResponse, redirectToCanonicalPath, slashVariantOf, } from "../page-render/index.js";
5
4
  import { LEGAL_REQUIRED_BLOCKS, LEGAL_ROUTES, } from "./constants.js";
5
+ import { runLegalPagesBootCheck } from "./lib/boot-check.js";
6
+ import { validateRoutes } from "./lib/validate-routes.js";
6
7
  import { renderMarkdownToHtml, wrapInLayout } from "./markdown.js";
8
+ export { runLegalPagesBootCheck } from "./lib/boot-check.js";
7
9
  // QN-Konstante als dokumentierter Public-Contract des template-resolver-
8
10
  // Features. Ein magic-string statt eines Code-Imports ist hier explizit
9
11
  // gewollt: Cross-Feature-Calls gehen nur über stable Public-API
@@ -14,23 +16,6 @@ import { renderMarkdownToHtml, wrapInLayout } from "./markdown.js";
14
16
  const TEXT_BLOCK_BY_SLUG_QN = "template-resolver:query:by-slug";
15
17
  // 60s-shared-cache saves the origin-revalidate roundtrip; legal-content edits are live within 60s.
16
18
  const PUBLIC_PAGE_CACHE = { kind: "revalidate", maxAgeSeconds: 60 };
17
- // Validated once at feature-build time (not per-request) so a config typo
18
- // fails app startup instead of surfacing as a confusing 404 later.
19
- function validateRoutes(routes) {
20
- const seenPaths = new Set();
21
- for (const route of routes) {
22
- if (!route.path || !route.slug || !route.lang) {
23
- throw new Error(`legal-pages: route is missing a required field (path/slug/lang): ${JSON.stringify(route)}`);
24
- }
25
- if (!route.path.startsWith("/")) {
26
- throw new Error(`legal-pages: route path must start with "/", got "${route.path}"`);
27
- }
28
- if (seenPaths.has(route.path)) {
29
- throw new Error(`legal-pages: duplicate route path "${route.path}"`);
30
- }
31
- seenPaths.add(route.path);
32
- }
33
- }
34
19
  export function createLegalPagesFeature(opts = {}) {
35
20
  const wrapLayout = opts.wrapLayout ?? wrapInLayout;
36
21
  const routes = opts.routes ?? LEGAL_ROUTES;
@@ -56,6 +41,8 @@ export function createLegalPagesFeature(opts = {}) {
56
41
  method: "GET",
57
42
  path: route.path,
58
43
  anonymous: true,
44
+ // Public pages backed by a DB query: bound the per-IP read rate.
45
+ rateLimit: { per: "ip+handler", limit: 60, windowSeconds: 60 },
59
46
  handler: async (c, { systemQuery }) => {
60
47
  // Architektur: 1 App = X Tenants = 1 Impressum. Egal welche
61
48
  // Subdomain der Visitor besucht (apex, admin.*, tenant-x.*) —
@@ -113,6 +100,20 @@ export function createLegalPagesFeature(opts = {}) {
113
100
  },
114
101
  });
115
102
  }
103
+ // Hono matches strictly, so "/legal/x/" would 404 for sites that link with
104
+ // a trailing slash. GET also serves HEAD.
105
+ const configuredPaths = new Set(routes.map((route) => route.path));
106
+ for (const route of routes) {
107
+ const variant = slashVariantOf(route.path);
108
+ if (variant === null || configuredPaths.has(variant))
109
+ continue;
110
+ r.httpRoute({
111
+ method: "GET",
112
+ path: variant,
113
+ anonymous: true,
114
+ handler: (c) => redirectToCanonicalPath(c.req.url, route.path),
115
+ });
116
+ }
116
117
  // Boot-Check via ctx.templateResolver (extraContext-Pattern, symmetrisch
117
118
  // zu requireConfigResolver in config). App-Bootstrap muss templateResolver
118
119
  // wired haben — der Helper gibt einen klaren Wiring-Hinweis wenn nicht.
@@ -132,34 +133,3 @@ export function createLegalPagesFeature(opts = {}) {
132
133
  return {};
133
134
  });
134
135
  }
135
- // Exportiert für direkte Tests. Wirft InternalError wenn ctx.templateResolver
136
- // nicht gewired ist (Hinweis auf fehlenden extraContext). Wirft Error
137
- // in NODE_ENV=production wenn Pflicht-Blocks fehlen, sonst log.warn.
138
- // Logged log.info wenn alles vorhanden ist (kein silent-skip).
139
- export async function runLegalPagesBootCheck(ctx, requiredBlocks = LEGAL_REQUIRED_BLOCKS) {
140
- const templateResolver = requireTemplateResolver(ctx, "legal-pages-boot-check");
141
- const missing = [];
142
- for (const required of requiredBlocks) {
143
- const block = await templateResolver.findExact({
144
- tenantId: SYSTEM_TENANT_ID,
145
- slug: required.slug,
146
- kind: TEXT_BLOCK_KIND,
147
- locale: required.lang,
148
- });
149
- if (!block?.content) {
150
- missing.push({ slug: required.slug, lang: required.lang });
151
- }
152
- }
153
- if (missing.length === 0) {
154
- ctx.log?.info?.("legal-pages boot-check: alle Pflicht-Blocks vorhanden");
155
- }
156
- else {
157
- const message = `legal-pages: missing ${missing.length} required text-block(s) in SYSTEM_TENANT: ` +
158
- missing.map((m) => `${m.slug}/${m.lang}`).join(", ") +
159
- ". Seed via template-resolver:write:set or the seedTextBlock helper.";
160
- if (process.env["NODE_ENV"] === "production") {
161
- throw new Error(`Boot-Validation failed: ${message}`);
162
- }
163
- ctx.log?.warn?.(message);
164
- }
165
- }
@@ -0,0 +1,10 @@
1
+ import { type TemplateResolverApi } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
2
+ import { type LegalRequiredBlock } from "../constants.js";
3
+ export type LegalPagesBootCheckCtx = {
4
+ readonly templateResolver?: TemplateResolverApi;
5
+ readonly log?: {
6
+ readonly info?: (msg: string) => void;
7
+ readonly warn?: (msg: string) => void;
8
+ };
9
+ };
10
+ export declare function runLegalPagesBootCheck(ctx: LegalPagesBootCheckCtx, requiredBlocks?: readonly LegalRequiredBlock[]): Promise<void>;
@@ -0,0 +1,33 @@
1
+ import { requireTemplateResolver, TEXT_BLOCK_KIND, } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
2
+ import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { LEGAL_REQUIRED_BLOCKS } from "../constants.js";
4
+ // Missing required blocks fail the boot only in production; elsewhere they warn,
5
+ // so a dev setup without seeded legal texts still starts. A complete set logs
6
+ // info on purpose, so a skipped check is visible.
7
+ export async function runLegalPagesBootCheck(ctx, requiredBlocks = LEGAL_REQUIRED_BLOCKS) {
8
+ const templateResolver = requireTemplateResolver(ctx, "legal-pages-boot-check");
9
+ const missing = [];
10
+ for (const required of requiredBlocks) {
11
+ const block = await templateResolver.findExact({
12
+ tenantId: SYSTEM_TENANT_ID,
13
+ slug: required.slug,
14
+ kind: TEXT_BLOCK_KIND,
15
+ locale: required.lang,
16
+ });
17
+ if (!block?.content) {
18
+ missing.push({ slug: required.slug, lang: required.lang });
19
+ }
20
+ }
21
+ if (missing.length === 0) {
22
+ ctx.log?.info?.("legal-pages boot-check: alle Pflicht-Blocks vorhanden");
23
+ }
24
+ else {
25
+ const message = `legal-pages: missing ${missing.length} required text-block(s) in SYSTEM_TENANT: ` +
26
+ missing.map((m) => `${m.slug}/${m.lang}`).join(", ") +
27
+ ". Seed via template-resolver:write:set or the seedTextBlock helper.";
28
+ if (process.env["NODE_ENV"] === "production") {
29
+ throw new Error(`Boot-Validation failed: ${message}`);
30
+ }
31
+ ctx.log?.warn?.(message);
32
+ }
33
+ }
@@ -0,0 +1,2 @@
1
+ import type { LegalPageRoute } from "../constants.js";
2
+ export declare function validateRoutes(routes: readonly LegalPageRoute[]): void;
@@ -0,0 +1,17 @@
1
+ // Validated once at feature-build time (not per-request) so a config typo
2
+ // fails app startup instead of surfacing as a confusing 404 later.
3
+ export function validateRoutes(routes) {
4
+ const seenPaths = new Set();
5
+ for (const route of routes) {
6
+ if (!route.path || !route.slug || !route.lang) {
7
+ throw new Error(`legal-pages: route is missing a required field (path/slug/lang): ${JSON.stringify(route)}`);
8
+ }
9
+ if (!route.path.startsWith("/")) {
10
+ throw new Error(`legal-pages: route path must start with "/", got "${route.path}"`);
11
+ }
12
+ if (seenPaths.has(route.path)) {
13
+ throw new Error(`legal-pages: duplicate route path "${route.path}"`);
14
+ }
15
+ seenPaths.add(route.path);
16
+ }
17
+ }
@@ -1,6 +1,7 @@
1
1
  export { type BrandingTokens, brandingHeaderHtml, brandingStyleBlock, EMPTY_BRANDING, isSafeHexColor, isSafeHttpsUrl, layoutMaxWidth, } from "./branding.js";
2
2
  export { type CachedSecurePageResponseInit, cachedSecurePageResponse, } from "./cached-page-response.js";
3
3
  export { sanitizeTenantCss } from "./css-sanitize.js";
4
- export { type SeoHeadInput, TENANT_CONTENT_ATTR, tenantStyleBlock, wrapInLayout, } from "./layout.js";
4
+ export { type PublicPageWrapLayout, type SeoHeadInput, TENANT_CONTENT_ATTR, tenantStyleBlock, wrapInLayout, } from "./layout.js";
5
5
  export { renderSafeMarkdown } from "./markdown.js";
6
6
  export { securePageHeaders } from "./security-headers.js";
7
+ export { redirectToCanonicalPath, slashVariantOf } from "./trailing-slash.js";
@@ -4,3 +4,4 @@ export { sanitizeTenantCss } from "./css-sanitize.js";
4
4
  export { TENANT_CONTENT_ATTR, tenantStyleBlock, wrapInLayout, } from "./layout.js";
5
5
  export { renderSafeMarkdown } from "./markdown.js";
6
6
  export { securePageHeaders } from "./security-headers.js";
7
+ export { redirectToCanonicalPath, slashVariantOf } from "./trailing-slash.js";
@@ -1,6 +1,13 @@
1
1
  import { type ApexHead } from "@cosmicdrift/kumiko-headless/apex";
2
2
  import { type BrandingTokens } from "./branding.js";
3
3
  export type SeoHeadInput = Omit<ApexHead, "title" | "description" | "lang">;
4
+ export type PublicPageWrapLayout = (opts: {
5
+ readonly title: string;
6
+ readonly bodyHtml: string;
7
+ readonly lang: string;
8
+ readonly slug?: string;
9
+ readonly alternates?: Readonly<Record<string, string>>;
10
+ }) => string;
4
11
  export declare const TENANT_CONTENT_ATTR = "data-tenant-content";
5
12
  export declare function tenantStyleBlock(customCss: string): string;
6
13
  export declare function wrapInLayout(opts: {
@@ -81,6 +81,11 @@ ${headHtml}
81
81
  a { color: var(--accent); }
82
82
  code { background: #f4f4f4; padding: 0.1rem 0.3rem; border-radius: 3px; }
83
83
  hr { border: 0; border-top: 1px solid #ddd; margin: 2rem 0; }
84
+ input, select, textarea, button { font: inherit; color: inherit; padding: 0.4rem 0.6rem;
85
+ border: 1px solid #bbb; border-radius: 4px; box-sizing: border-box; }
86
+ input:not([type="radio"]):not([type="checkbox"]):not([type="hidden"]), select, textarea {
87
+ width: 100%; max-width: 100%; }
88
+ button { cursor: pointer; background: #f4f4f4; }
84
89
  .brand-header { position: relative; z-index: 1; display: flex; align-items: center;
85
90
  gap: 0.6rem; margin-bottom: 1.5rem; }
86
91
  .brand-header a { display: flex; align-items: center; gap: 0.6rem; color: inherit; text-decoration: none; }
@@ -0,0 +1,2 @@
1
+ export declare function slashVariantOf(path: string): string | null;
2
+ export declare function redirectToCanonicalPath(requestUrl: string, canonicalPath: string): Response;
@@ -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) {
4
+ if (path === "/")
5
+ return null;
6
+ return path.endsWith("/") ? path.slice(0, -1) : `${path}/`;
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, canonicalPath) {
11
+ return new Response(null, {
12
+ status: 301,
13
+ headers: { location: `${canonicalPath}${new URL(requestUrl).search}` },
14
+ });
15
+ }
@@ -19,6 +19,7 @@ export async function adaptToFoundation(req, ctx, renderer = simpleRenderer) {
19
19
  const html = await renderer.render({
20
20
  template: req.payload.template ?? "",
21
21
  variables,
22
+ locale: req.payload.locale,
22
23
  });
23
24
  return { kind: "notification", html };
24
25
  }
@@ -1,2 +1,2 @@
1
1
  export { createRendererSimpleFeature, type RendererSimpleOptions } from "./feature.js";
2
- export { createSimpleRenderer, type MailBranding, simpleRenderer } from "./simple-renderer.js";
2
+ export { createSimpleRenderer, type LocalizedText, type MailBranding, simpleRenderer, } from "./simple-renderer.js";
@@ -1,2 +1,2 @@
1
1
  export { createRendererSimpleFeature } from "./feature.js";
2
- export { createSimpleRenderer, simpleRenderer } from "./simple-renderer.js";
2
+ export { createSimpleRenderer, simpleRenderer, } from "./simple-renderer.js";
@@ -4,14 +4,21 @@ export type MailBranding = {
4
4
  readonly productName?: string;
5
5
  /** Hex color (#rgb, #rrggbb, #rrggbbaa); used for buttons and the header accent. */
6
6
  readonly primaryColor?: string;
7
- /** Absolute http(s) URL; anything else is dropped. */
7
+ /** Absolute http(s) URL; anything else is dropped. Mutually exclusive with `logoPath`. */
8
8
  readonly logoUrl?: string;
9
- readonly footerText?: string;
10
- /** Absolute http(s) URLs only; other entries are dropped. */
9
+ /** Absolute path on `baseUrl` (e.g. "/logo.png"); use PNG/JPEG, mail clients block SVG. */
10
+ readonly logoPath?: string;
11
+ /** Absolute http(s) origin that `logoPath` is resolved against; same base as `auth.mail.baseUrl`. */
12
+ readonly baseUrl?: string;
13
+ /** Locale tried after the exact and language-part match when a localized field has no entry. */
14
+ readonly defaultLocale?: string;
15
+ readonly footerText?: LocalizedText;
16
+ /** Absolute http(s) URLs only; string URLs that are not are dropped, localized ones fail at boot. */
11
17
  readonly footerLinks?: readonly {
12
- readonly label: string;
13
- readonly url: string;
18
+ readonly label: LocalizedText;
19
+ readonly url: LocalizedText;
14
20
  }[];
15
21
  };
22
+ export type LocalizedText = string | Readonly<Record<string, string>>;
16
23
  export declare function createSimpleRenderer(branding?: MailBranding): NotificationRenderer;
17
24
  export declare const simpleRenderer: NotificationRenderer;
@@ -10,14 +10,61 @@ function isHttpUrl(value) {
10
10
  return false;
11
11
  }
12
12
  }
13
+ function languagePart(locale) {
14
+ return locale.split("-")[0] ?? locale;
15
+ }
16
+ function pickByLocale(entries, locale) {
17
+ // Own keys only: a locale like "constructor" must not hit Object.prototype.
18
+ const ownEntry = (key) => Object.hasOwn(entries, key) ? entries[key] : undefined;
19
+ return ownEntry(locale) ?? ownEntry(languagePart(locale));
20
+ }
21
+ function resolveLocalized(value, locale, defaultLocale) {
22
+ if (value === undefined || typeof value === "string")
23
+ return value;
24
+ return ((locale !== undefined ? pickByLocale(value, locale) : undefined) ??
25
+ (defaultLocale !== undefined ? pickByLocale(value, defaultLocale) : undefined) ??
26
+ Object.values(value)[0]);
27
+ }
28
+ function assertLocalizedUrlsAreHttp(branding) {
29
+ for (const link of branding.footerLinks ?? []) {
30
+ if (typeof link.url === "string")
31
+ continue;
32
+ for (const [locale, url] of Object.entries(link.url)) {
33
+ if (!isHttpUrl(url)) {
34
+ throw new Error(`createSimpleRenderer: footerLinks url for locale "${locale}" must be an absolute http(s) URL`);
35
+ }
36
+ }
37
+ }
38
+ }
39
+ function resolveLogoUrl(branding) {
40
+ if (!branding)
41
+ return undefined;
42
+ const { logoUrl, logoPath, baseUrl } = branding;
43
+ if (logoPath === undefined) {
44
+ return logoUrl && isHttpUrl(logoUrl) ? logoUrl : undefined;
45
+ }
46
+ if (logoUrl !== undefined) {
47
+ throw new Error("createSimpleRenderer: logoPath and logoUrl are mutually exclusive");
48
+ }
49
+ if (baseUrl === undefined || !isHttpUrl(baseUrl)) {
50
+ throw new Error("createSimpleRenderer: logoPath requires an absolute http(s) baseUrl");
51
+ }
52
+ if (!/^\/(?![/\\])/.test(logoPath) || logoPath.includes("\\")) {
53
+ throw new Error('createSimpleRenderer: logoPath must start with a single "/"');
54
+ }
55
+ const resolved = new URL(logoPath, baseUrl);
56
+ if (resolved.origin !== new URL(baseUrl).origin) {
57
+ throw new Error("createSimpleRenderer: logoPath must stay on the baseUrl origin");
58
+ }
59
+ return resolved.href;
60
+ }
13
61
  function resolvePrimaryColor(branding) {
14
62
  const color = branding?.primaryColor;
15
63
  return color && HEX_COLOR_PATTERN.test(color) ? color : DEFAULT_PRIMARY_COLOR;
16
64
  }
17
- function renderBrandingHeader(branding, primaryColor) {
65
+ function renderBrandingHeader(branding, primaryColor, logoUrl) {
18
66
  if (!branding)
19
67
  return "";
20
- const logoUrl = branding.logoUrl && isHttpUrl(branding.logoUrl) ? branding.logoUrl : undefined;
21
68
  if (!logoUrl && !branding.productName)
22
69
  return "";
23
70
  const logo = logoUrl
@@ -25,16 +72,20 @@ function renderBrandingHeader(branding, primaryColor) {
25
72
  : `<span style="font-size:18px;font-weight:700;color:${escapeHtmlAttr(primaryColor)}">${escapeHtml(branding.productName ?? "")}</span>`;
26
73
  return `<div style="margin:0 0 24px;padding:0 0 16px;border-bottom:3px solid ${escapeHtmlAttr(primaryColor)}">${logo}</div>`;
27
74
  }
28
- function renderBrandingFooter(branding) {
75
+ function renderBrandingFooter(branding, locale) {
29
76
  if (!branding)
30
77
  return "";
31
- const links = (branding.footerLinks ?? [])
32
- .filter((link) => isHttpUrl(link.url))
33
- .map((link) => `<a href="${escapeHtmlAttr(link.url)}" style="color:#999">${escapeHtml(link.label)}</a>`);
34
- const footerPartsHtml = [
35
- branding.footerText ? escapeHtml(branding.footerText) : "",
36
- links.join(" · "),
37
- ].filter((part) => part !== "");
78
+ const pick = (value) => resolveLocalized(value, locale, branding.defaultLocale);
79
+ const links = (branding.footerLinks ?? []).flatMap((link) => {
80
+ const url = pick(link.url);
81
+ if (url === undefined || !isHttpUrl(url))
82
+ return [];
83
+ return [
84
+ `<a href="${escapeHtmlAttr(url)}" style="color:#999">${escapeHtml(pick(link.label) ?? "")}</a>`,
85
+ ];
86
+ });
87
+ const footerText = pick(branding.footerText);
88
+ const footerPartsHtml = [footerText ? escapeHtml(footerText) : "", links.join(" · ")].filter((part) => part !== "");
38
89
  if (footerPartsHtml.length === 0)
39
90
  return "";
40
91
  const footerHtml = footerPartsHtml.join("<br />");
@@ -53,6 +104,9 @@ function renderSection(section, primaryColor) {
53
104
  // No external dependencies, no template engine — just string concatenation.
54
105
  export function createSimpleRenderer(branding) {
55
106
  const primaryColor = resolvePrimaryColor(branding);
107
+ const logoUrl = resolveLogoUrl(branding);
108
+ if (branding)
109
+ assertLocalizedUrlsAreHttp(branding);
56
110
  return {
57
111
  name: "simple",
58
112
  async render(input) {
@@ -63,7 +117,7 @@ export function createSimpleRenderer(branding) {
63
117
  const parts = [];
64
118
  parts.push('<!DOCTYPE html><html><body style="margin:0;padding:0;font-family:sans-serif">');
65
119
  parts.push('<div style="max-width:600px;margin:0 auto;padding:24px">');
66
- parts.push(renderBrandingHeader(branding, primaryColor));
120
+ parts.push(renderBrandingHeader(branding, primaryColor, logoUrl));
67
121
  if (header) {
68
122
  parts.push(`<h1 style="margin:0 0 24px;color:#111;font-size:20px;font-weight:600">${escapeHtml(header)}</h1>`);
69
123
  }
@@ -75,7 +129,7 @@ export function createSimpleRenderer(branding) {
75
129
  if (data.footer) {
76
130
  parts.push(`<p style="margin:24px 0 0;color:#999;font-size:12px;border-top:1px solid #eee;padding-top:16px">${escapeHtml(data.footer)}</p>`);
77
131
  }
78
- parts.push(renderBrandingFooter(branding));
132
+ parts.push(renderBrandingFooter(branding, input.locale));
79
133
  parts.push("</div></body></html>");
80
134
  return parts.join("");
81
135
  },
@@ -0,0 +1,8 @@
1
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { type CacheSyncBus } from "@cosmicdrift/kumiko-framework/redis";
3
+ type AnnouncingContext = {
4
+ readonly cacheSync?: CacheSyncBus;
5
+ readonly scheduleAfterCommit?: (hook: () => Promise<void>) => void;
6
+ };
7
+ export declare function announceSecretChange(ctx: AnnouncingContext, tenantId: TenantId, key: string): void;
8
+ export {};
@@ -0,0 +1,18 @@
1
+ import { CACHE_SYNC_TOPICS, } from "@cosmicdrift/kumiko-framework/redis";
2
+ // Secret writes bypass config:write:*, yet provider caches (e.g. the S3 secret
3
+ // key) depend on them: announce on the tenant-config topic, after commit so a
4
+ // replica reloading on the message sees the new value.
5
+ export function announceSecretChange(ctx, tenantId, key) {
6
+ const bus = ctx.cacheSync;
7
+ // skip: no bus wired (single-process setup), nothing to announce
8
+ if (!bus)
9
+ return;
10
+ const message = { tenantId, key };
11
+ const publish = async () => {
12
+ bus.publish(CACHE_SYNC_TOPICS.tenantConfig, message);
13
+ };
14
+ if (ctx.scheduleAfterCommit)
15
+ ctx.scheduleAfterCommit(publish);
16
+ else
17
+ bus.publish(CACHE_SYNC_TOPICS.tenantConfig, message);
18
+ }
@@ -54,7 +54,7 @@ export function requireSecretsContext(ctx, handlerName) {
54
54
  if (!ctx.secrets) {
55
55
  throw new InternalError({
56
56
  message: `[${handlerName}] ctx.secrets missing — pass ` +
57
- "createSecretsContext({db, masterKeyProvider}) via extraContext.secrets at boot.",
57
+ "createSecretsContext({db, masterKeyProvider, registry}) via extraContext.secrets at boot.",
58
58
  });
59
59
  }
60
60
  const raw = ctx.secrets;
@@ -1,6 +1,7 @@
1
1
  import { 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 = DEFAULT_SECRETS_ACCESS) {
23
24
  });
24
25
  if (!removed)
25
26
  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
  });
@@ -1,8 +1,9 @@
1
1
  import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import * as z from "zod";
3
+ import { announceSecretChange } from "../announce-secret-change.js";
3
4
  import { DEFAULT_SECRETS_ACCESS } from "../constants.js";
4
5
  import { requireSecretsContext } from "../feature.js";
5
- import { checkSecretKeyWrite } from "../write-gate.js";
6
+ import { checkSecretKeyWrite, checkSecretValue } from "../write-gate.js";
6
7
  export function createSetHandler(access = DEFAULT_SECRETS_ACCESS) {
7
8
  return defineWriteHandler({
8
9
  name: "set",
@@ -30,6 +31,9 @@ export function createSetHandler(access = DEFAULT_SECRETS_ACCESS) {
30
31
  const gate = checkSecretKeyWrite(ctx.registry, event.user.roles, key);
31
32
  if (!gate.ok)
32
33
  return gate.failure;
34
+ const invalidValue = checkSecretValue(ctx.registry, key, value);
35
+ if (invalidValue)
36
+ return invalidValue;
33
37
  const featureRedact = gate.keyDef?.redact;
34
38
  const redactFn = redactedPreview
35
39
  ? () => redactedPreview
@@ -39,6 +43,7 @@ export function createSetHandler(access = DEFAULT_SECRETS_ACCESS) {
39
43
  ...(hint ? { hint } : {}),
40
44
  updatedBy: event.user.id,
41
45
  });
46
+ announceSecretChange(ctx, event.user.tenantId, key);
42
47
  return {
43
48
  isSuccess: true,
44
49
  data: { key, redactedPreview: redactedPreview ?? redactFn(value) },
@@ -7,4 +7,8 @@ export declare const SECRETS_I18N: {
7
7
  readonly en: "You are not allowed to change this secret.";
8
8
  readonly de: "Dieses Geheimnis darf nicht geändert werden.";
9
9
  };
10
+ readonly "secrets.errors.invalidValue": {
11
+ readonly en: "This value is not valid for this secret.";
12
+ readonly de: "Dieser Wert ist für dieses Geheimnis nicht gültig.";
13
+ };
10
14
  };
@@ -7,4 +7,8 @@ export const SECRETS_I18N = {
7
7
  en: "You are not allowed to change this secret.",
8
8
  de: "Dieses Geheimnis darf nicht geändert werden.",
9
9
  },
10
+ "secrets.errors.invalidValue": {
11
+ en: "This value is not valid for this secret.",
12
+ de: "Dieser Wert ist für dieses Geheimnis nicht gültig.",
13
+ },
10
14
  };
@@ -1,10 +1,12 @@
1
1
  import { type DbConnection } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { type DekCache, type MasterKeyProvider, type SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
3
3
  import * as z from "zod";
4
+ import { type SecretValueSchemaSource } from "./write-gate.js";
4
5
  export type { Secret, SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
5
6
  export type SecretsContextOptions = {
6
7
  readonly db: DbConnection;
7
8
  readonly masterKeyProvider: MasterKeyProvider;
9
+ readonly registry: SecretValueSchemaSource;
8
10
  readonly dekCache?: DekCache;
9
11
  };
10
12
  export declare const TENANT_SECRET_READ_EVENT = "secrets:event:read";
@@ -20,6 +20,7 @@ import { generateId } from "@cosmicdrift/kumiko-framework/utils";
20
20
  import * as z from "zod";
21
21
  import { selectTenantSecretEnvelope } from "./db/queries/read.js";
22
22
  import { tenantSecretEntity, tenantSecretsTable, } from "./table.js";
23
+ import { invalidSecretValueError, isSecretValueValid, } from "./write-gate.js";
23
24
  // Synthetic actor identity for set/delete — the executor wants a full
24
25
  // SessionUser, but the secrets-context API takes only a user-id string
25
26
  // (via opts.updatedBy / opts.deletedBy). `system`-role mirrors how jobs
@@ -114,6 +115,8 @@ export function createSecretsContext(opts) {
114
115
  },
115
116
  async set(tenantId, keyOrHandle, value, setOpts = {}) {
116
117
  const key = resolveKey(keyOrHandle);
118
+ if (!isSecretValueValid(opts.registry, key, value))
119
+ throw invalidSecretValueError();
117
120
  const envelope = await encryptValue(value, masterKeyProvider);
118
121
  const stored = encodeStoredEnvelope(envelope);
119
122
  const metadata = {
@@ -1,5 +1,5 @@
1
1
  import type { Registry, SecretKeyDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
- import { type WriteFailure } from "@cosmicdrift/kumiko-framework/errors";
2
+ import { ValidationError, type WriteFailure } from "@cosmicdrift/kumiko-framework/errors";
3
3
  export type SecretKeyWriteCheck = {
4
4
  readonly ok: true;
5
5
  readonly keyDef: SecretKeyDefinition | undefined;
@@ -8,3 +8,7 @@ export type SecretKeyWriteCheck = {
8
8
  readonly failure: WriteFailure;
9
9
  };
10
10
  export declare function checkSecretKeyWrite(registry: Registry, userRoles: readonly string[], key: string): SecretKeyWriteCheck;
11
+ export type SecretValueSchemaSource = Pick<Registry, "getSecretKey" | "findSecretNamespace">;
12
+ export declare function invalidSecretValueError(): ValidationError;
13
+ export declare function isSecretValueValid(schemas: SecretValueSchemaSource, key: string, value: string): boolean;
14
+ export declare function checkSecretValue(registry: SecretValueSchemaSource, key: string, value: string): WriteFailure | undefined;