@cosmicdrift/kumiko-bundled-features 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 (166) 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/billing-foundation/projection.js +23 -40
  35. package/dist/channel-discord/discord-channel.d.ts +4 -0
  36. package/dist/channel-discord/discord-channel.js +4 -0
  37. package/dist/channel-discord/feature.js +7 -2
  38. package/dist/channel-discord/index.d.ts +1 -1
  39. package/dist/channel-discord/index.js +1 -1
  40. package/dist/channel-email/email-channel.js +1 -0
  41. package/dist/channel-slack/feature.js +4 -2
  42. package/dist/channel-slack/index.d.ts +1 -1
  43. package/dist/channel-slack/index.js +1 -1
  44. package/dist/channel-slack/slack-channel.d.ts +4 -0
  45. package/dist/channel-slack/slack-channel.js +4 -0
  46. package/dist/channel-teams/feature.js +4 -2
  47. package/dist/channel-teams/index.d.ts +1 -1
  48. package/dist/channel-teams/index.js +1 -1
  49. package/dist/channel-teams/teams-channel.d.ts +4 -0
  50. package/dist/channel-teams/teams-channel.js +4 -0
  51. package/dist/channel-telegram/feature.js +4 -2
  52. package/dist/channel-telegram/index.d.ts +1 -1
  53. package/dist/channel-telegram/index.js +1 -1
  54. package/dist/channel-telegram/telegram-channel.d.ts +8 -0
  55. package/dist/channel-telegram/telegram-channel.js +28 -12
  56. package/dist/delivery/chat-webhook-channel.d.ts +5 -1
  57. package/dist/delivery/chat-webhook-channel.js +10 -5
  58. package/dist/delivery/chat-webhook-sender.d.ts +6 -2
  59. package/dist/delivery/chat-webhook-sender.js +13 -8
  60. package/dist/delivery/delivery-service.d.ts +4 -3
  61. package/dist/delivery/delivery-service.js +53 -33
  62. package/dist/delivery/index.d.ts +2 -2
  63. package/dist/delivery/index.js +2 -2
  64. package/dist/delivery/jobs.js +3 -1
  65. package/dist/delivery/types.d.ts +5 -3
  66. package/dist/inbound-mail-foundation/projection.js +37 -45
  67. package/dist/legal-pages/feature.d.ts +3 -15
  68. package/dist/legal-pages/feature.js +20 -50
  69. package/dist/legal-pages/lib/boot-check.d.ts +10 -0
  70. package/dist/legal-pages/lib/boot-check.js +33 -0
  71. package/dist/legal-pages/lib/validate-routes.d.ts +2 -0
  72. package/dist/legal-pages/lib/validate-routes.js +17 -0
  73. package/dist/page-render/index.d.ts +2 -1
  74. package/dist/page-render/index.js +1 -0
  75. package/dist/page-render/layout.d.ts +7 -0
  76. package/dist/page-render/layout.js +5 -0
  77. package/dist/page-render/trailing-slash.d.ts +2 -0
  78. package/dist/page-render/trailing-slash.js +15 -0
  79. package/dist/renderer-simple/feature.js +1 -0
  80. package/dist/renderer-simple/index.d.ts +1 -1
  81. package/dist/renderer-simple/index.js +1 -1
  82. package/dist/renderer-simple/simple-renderer.d.ts +12 -5
  83. package/dist/renderer-simple/simple-renderer.js +66 -12
  84. package/dist/secrets/announce-secret-change.d.ts +8 -0
  85. package/dist/secrets/announce-secret-change.js +18 -0
  86. package/dist/secrets/feature.js +1 -1
  87. package/dist/secrets/handlers/delete.write.js +2 -0
  88. package/dist/secrets/handlers/set.write.js +6 -1
  89. package/dist/secrets/i18n.d.ts +4 -0
  90. package/dist/secrets/i18n.js +4 -0
  91. package/dist/secrets/secrets-context.d.ts +2 -0
  92. package/dist/secrets/secrets-context.js +3 -0
  93. package/dist/secrets/write-gate.d.ts +5 -1
  94. package/dist/secrets/write-gate.js +24 -1
  95. package/dist/tenant/constants.d.ts +1 -0
  96. package/dist/tenant/constants.js +1 -0
  97. package/dist/tenant/handlers/add-member.write.js +14 -3
  98. package/dist/tenant/handlers/invitations.query.d.ts +8 -0
  99. package/dist/tenant/handlers/invitations.query.js +15 -1
  100. package/dist/tenant/index.d.ts +1 -0
  101. package/dist/tenant/index.js +1 -0
  102. package/dist/tenant/invitation-table.d.ts +2 -0
  103. package/dist/tenant/invitation-table.js +10 -1
  104. package/dist/tenant-lifecycle/lifecycle-gate.d.ts +1 -0
  105. package/dist/tenant-lifecycle/lifecycle-gate.js +2 -0
  106. package/dist/tenant-lifecycle/run-tenant-destroy.js +2 -2
  107. package/dist/tier-engine/feature.js +83 -14
  108. package/dist/tier-engine/handlers/set-tenant-tier.write.d.ts +3 -3
  109. package/dist/tier-engine/handlers/set-tenant-tier.write.js +17 -10
  110. package/dist/user/handlers/update-roles.js +7 -18
  111. package/dist/user/invited-global-roles.d.ts +14 -0
  112. package/dist/user/invited-global-roles.js +35 -0
  113. package/dist/user-data-rights/feature.js +3 -0
  114. package/dist/waitlist/constants.d.ts +33 -0
  115. package/dist/waitlist/constants.js +35 -0
  116. package/dist/waitlist/entity.d.ts +433 -0
  117. package/dist/waitlist/entity.js +64 -0
  118. package/dist/waitlist/feature.d.ts +3 -0
  119. package/dist/waitlist/feature.js +38 -0
  120. package/dist/waitlist/handlers/invite.write.d.ts +9 -0
  121. package/dist/waitlist/handlers/invite.write.js +119 -0
  122. package/dist/waitlist/handlers/list.query.d.ts +1 -0
  123. package/dist/waitlist/handlers/list.query.js +10 -0
  124. package/dist/waitlist/handlers/reject.write.d.ts +7 -0
  125. package/dist/waitlist/handlers/reject.write.js +80 -0
  126. package/dist/waitlist/handlers/submit.write.d.ts +13 -0
  127. package/dist/waitlist/handlers/submit.write.js +112 -0
  128. package/dist/waitlist/i18n.d.ts +6 -0
  129. package/dist/waitlist/i18n.js +31 -0
  130. package/dist/waitlist/index.d.ts +5 -0
  131. package/dist/waitlist/index.js +4 -0
  132. package/dist/waitlist/lib.d.ts +10 -0
  133. package/dist/waitlist/lib.js +24 -0
  134. package/dist/waitlist/mail.d.ts +15 -0
  135. package/dist/waitlist/mail.js +73 -0
  136. package/dist/waitlist/options.d.ts +29 -0
  137. package/dist/waitlist/options.js +8 -0
  138. package/dist/waitlist/payloads.d.ts +14 -0
  139. package/dist/waitlist/payloads.js +47 -0
  140. package/dist/waitlist/screens.d.ts +2 -0
  141. package/dist/waitlist/screens.js +42 -0
  142. package/dist/waitlist/user-data-hooks.d.ts +3 -0
  143. package/dist/waitlist/user-data-hooks.js +71 -0
  144. package/dist/workflow-runner/db/queries/due-runs.d.ts +4 -5
  145. package/dist/workflow-runner/db/queries/due-runs.js +5 -4
  146. package/dist/workflow-runner/feature.js +4 -6
  147. package/package.json +17 -9
  148. package/src/auth-email-password/changes.json +18 -0
  149. package/src/auth-mfa/changes.json +6 -0
  150. package/src/billing-foundation/changes.json +13 -0
  151. package/src/channel-telegram/changes.json +9 -0
  152. package/src/delivery/changes.json +7 -0
  153. package/src/files/changes.json +13 -1
  154. package/src/inbound-mail-foundation/changes.json +7 -0
  155. package/src/renderer-simple/changes.json +6 -0
  156. package/src/secrets/changes.json +12 -0
  157. package/src/tenant/changes.json +6 -0
  158. package/src/user/changes.json +13 -6
  159. package/src/waitlist/changes.json +17 -0
  160. package/src/workflow-runner/changes.json +7 -0
  161. package/dist/billing-foundation/db/queries/payment-projection.d.ts +0 -2
  162. package/dist/billing-foundation/db/queries/payment-projection.js +0 -13
  163. package/dist/billing-foundation/db/queries/subscription-projection.d.ts +0 -2
  164. package/dist/billing-foundation/db/queries/subscription-projection.js +0 -7
  165. package/dist/inbound-mail-foundation/db/queries/inbound-projections.d.ts +0 -7
  166. package/dist/inbound-mail-foundation/db/queries/inbound-projections.js +0 -24
@@ -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;
@@ -1,4 +1,4 @@
1
- import { AccessDeniedError, NotFoundError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
1
+ import { AccessDeniedError, NotFoundError, ValidationError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
2
2
  function unknownKeyFailure(key) {
3
3
  return {
4
4
  ok: false,
@@ -34,3 +34,26 @@ export function checkSecretKeyWrite(registry, userRoles, key) {
34
34
  }
35
35
  return { ok: true, keyDef };
36
36
  }
37
+ // Carries neither the value nor the schema's issues.
38
+ export function invalidSecretValueError() {
39
+ return new ValidationError({
40
+ fields: [
41
+ {
42
+ path: "value",
43
+ code: "invalid_secret_value",
44
+ i18nKey: "secrets.errors.invalidValue",
45
+ },
46
+ ],
47
+ }, { i18nKey: "secrets.errors.invalidValue" });
48
+ }
49
+ export function isSecretValueValid(schemas, key, value) {
50
+ const valueSchema = schemas.getSecretKey(key)?.valueSchema ?? schemas.findSecretNamespace(key)?.valueSchema;
51
+ return valueSchema === undefined || valueSchema.safeParse(value).success;
52
+ }
53
+ // Runs after checkSecretKeyWrite, so a caller without the write role never learns
54
+ // whether a value would have passed.
55
+ export function checkSecretValue(registry, key, value) {
56
+ if (isSecretValueValid(registry, key, value))
57
+ return undefined;
58
+ return writeFailure(invalidSecretValueError());
59
+ }
@@ -39,4 +39,5 @@ export declare const TenantErrors: {
39
39
  readonly membershipAlreadyExists: "membership_already_exists";
40
40
  readonly lastTenantAdmin: "last_tenant_admin";
41
41
  readonly invitationSuperseded: "invitation_superseded";
42
+ readonly crossTenantMembershipDenied: "cross_tenant_membership_denied";
42
43
  };
@@ -65,4 +65,5 @@ export const TenantErrors = {
65
65
  membershipAlreadyExists: "membership_already_exists",
66
66
  lastTenantAdmin: "last_tenant_admin",
67
67
  invitationSuperseded: "invitation_superseded",
68
+ crossTenantMembershipDenied: "cross_tenant_membership_denied",
68
69
  };
@@ -1,7 +1,7 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
3
- import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
4
- import { ConflictError, InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
+ import { defineWriteHandler, SYSTEM_ROLE, SYSTEM_USER_ID, } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { AccessDeniedError, ConflictError, InternalError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
5
5
  import * as z from "zod";
6
6
  import { TenantErrors } from "../constants.js";
7
7
  import { findForbiddenMembershipRole, reservedMembershipRoleError } from "../membership-roles.js";
@@ -9,6 +9,11 @@ import { tenantMembershipEntity, tenantMembershipsTable } from "../membership-ta
9
9
  const executor = createEventStoreExecutor(tenantMembershipsTable, tenantMembershipEntity, {
10
10
  entityName: "tenant-membership",
11
11
  });
12
+ // Both halves: test stacks can mint JWTs with the "system" role, but only the
13
+ // framework operator carries SYSTEM_USER_ID.
14
+ function isFrameworkSystemUser(user) {
15
+ return user.id === SYSTEM_USER_ID && user.roles.includes(SYSTEM_ROLE);
16
+ }
12
17
  export const addMemberWrite = defineWriteHandler({
13
18
  name: "addMember",
14
19
  schema: z.object({
@@ -16,7 +21,7 @@ export const addMemberWrite = defineWriteHandler({
16
21
  tenantId: z.string(),
17
22
  roles: z.array(z.string()).min(1),
18
23
  }),
19
- access: { roles: ["SystemAdmin"] },
24
+ access: { roles: [SYSTEM_ROLE, "SystemAdmin"] },
20
25
  description: "Grants an existing user membership in a tenant with the given roles, refusing reserved role names and a user who is already a member; use it to add someone to a workspace without going through an invitation.",
21
26
  handler: async (event, ctx) => {
22
27
  if (!ctx.systemDb) {
@@ -25,6 +30,12 @@ export const addMemberWrite = defineWriteHandler({
25
30
  });
26
31
  }
27
32
  const db = ctx.systemDb.acknowledgeCrossTenant("SystemAdmin manages memberships across tenants");
33
+ if (!isFrameworkSystemUser(event.user) && event.payload.tenantId !== event.user.tenantId) {
34
+ return writeFailure(new AccessDeniedError({
35
+ message: "only the system context may add members to another tenant",
36
+ details: { reason: TenantErrors.crossTenantMembershipDenied },
37
+ }));
38
+ }
28
39
  const forbidden = findForbiddenMembershipRole(event.payload.roles);
29
40
  if (forbidden !== undefined)
30
41
  return writeFailure(reservedMembershipRoleError(forbidden));
@@ -1,5 +1,13 @@
1
1
  import * as z from "zod";
2
2
  export declare const invitationsQuery: import("@cosmicdrift/kumiko-types/define-handler").QueryHandlerDefinition<"invitations", z.ZodObject<{}, z.core.$strip>, {
3
+ id: unknown;
4
+ tenantId: unknown;
3
5
  email: unknown;
6
+ role: unknown;
7
+ status: unknown;
4
8
  invitedBy: unknown;
9
+ expiresAt: unknown;
10
+ insertedAt: unknown;
11
+ version: unknown;
12
+ globalRoles?: unknown;
5
13
  }[], import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap>;
@@ -4,6 +4,7 @@ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
4
4
  import * as z from "zod";
5
5
  import { decryptStoredPii, mapWithConcurrency } from "../../shared/index.js";
6
6
  import { INVITATION_STATUS, tenantInvitationsTable } from "../invitation-table.js";
7
+ import { isSystemAdmin } from "./is-system-admin.js";
7
8
  // Bounded, not Promise.all/sequential: each decrypt hits the KMS adapter's
8
9
  // own small dedicated pool (PgKmsAdapter default max: 4). Promise.all fires
9
10
  // one call per row unbounded and exhausts the pool once invitation counts
@@ -46,7 +47,20 @@ export const invitationsQuery = defineQueryHandler({
46
47
  const decryptedInvitedBy = typeof invitedBy === "string"
47
48
  ? await decryptStoredPii(invitedBy, "invitedBy", "tenant:invitations")
48
49
  : invitedBy;
49
- return { ...row, email: decryptedEmail, invitedBy: decryptedInvitedBy };
50
+ // Explicit allowlist: the raw row also carries blind-index columns and
51
+ // globalRoles, which only a SystemAdmin may see.
52
+ return {
53
+ id: row["id"],
54
+ tenantId: row["tenantId"],
55
+ email: decryptedEmail,
56
+ role: row["role"],
57
+ status: row["status"],
58
+ invitedBy: decryptedInvitedBy,
59
+ expiresAt: row["expiresAt"],
60
+ insertedAt: row["insertedAt"],
61
+ version: row["version"],
62
+ ...(isSystemAdmin(query.user) && { globalRoles: row["globalRoles"] }),
63
+ };
50
64
  });
51
65
  },
52
66
  });
@@ -1,3 +1,4 @@
1
+ export { cancelPendingInvitation } from "./cancel-pending-invitation.js";
1
2
  export { TenantCommandSchemas } from "./command-schemas.js";
2
3
  export { OWNER_INVITE_ROLE_OPTIONS, TENANT_FEATURE, TenantErrors, TenantHandlers, TenantQueries, } from "./constants.js";
3
4
  export { collectAssignableAppRoles, createTenantFeature } from "./feature.js";
@@ -1,3 +1,4 @@
1
+ export { cancelPendingInvitation } from "./cancel-pending-invitation.js";
1
2
  export { TenantCommandSchemas } from "./command-schemas.js";
2
3
  export { OWNER_INVITE_ROLE_OPTIONS, TENANT_FEATURE, TenantErrors, TenantHandlers, TenantQueries, } from "./constants.js";
3
4
  export { collectAssignableAppRoles, createTenantFeature } from "./feature.js";
@@ -49,6 +49,7 @@ export declare const tenantInvitationEntity: import("@cosmicdrift/kumiko-types/f
49
49
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
50
50
  required: true;
51
51
  };
52
+ globalRoles: import("@cosmicdrift/kumiko-types/fields").MultiSelectFieldDef<readonly ["SystemAdmin"]>;
52
53
  status: {
53
54
  readonly type: "select";
54
55
  readonly description?: string;
@@ -151,6 +152,7 @@ export declare const tenantInvitationsTable: import("@cosmicdrift/kumiko-framewo
151
152
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
152
153
  required: true;
153
154
  };
155
+ globalRoles: import("@cosmicdrift/kumiko-types/fields").MultiSelectFieldDef<readonly ["SystemAdmin"]>;
154
156
  status: {
155
157
  readonly type: "select";
156
158
  readonly description?: string;
@@ -22,7 +22,9 @@
22
22
  // pending → re-use existing row + refresh Redis-token + send mail
23
23
  // (analog zu signup-Resend).
24
24
  import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
25
- import { createEntity, createSelectField, createTextField, createTimestampField, } from "@cosmicdrift/kumiko-framework/engine";
25
+ import { createEntity, createMultiSelectField, createSelectField, createTextField, createTimestampField, } from "@cosmicdrift/kumiko-framework/engine";
26
+ // kumiko-lint-ignore cross-feature-import global-role options are owned by the user feature
27
+ import { GLOBAL_ROLE_OPTIONS } from "../user/constants.js";
26
28
  // Status-const-Object damit Handler-Code keine Magic-Strings nutzt.
27
29
  // Bei rename (z.B. "cancelled" → "revoked") fällt jeder caller auf
28
30
  // einmal auf statt verstreut über 5 Stellen.
@@ -57,6 +59,13 @@ export const tenantInvitationEntity = createEntity({
57
59
  personal: false,
58
60
  reason: "technical_reference",
59
61
  }),
62
+ // Global (users.roles) roles granted on accept, on top of the membership
63
+ // role. Only the system-only invite variant sets them; every tenant-admin
64
+ // re-invite resets them to [] so a resend can never carry them over.
65
+ globalRoles: createMultiSelectField({
66
+ options: GLOBAL_ROLE_OPTIONS,
67
+ default: [],
68
+ }),
60
69
  // Lifecycle-State. Default "pending"; transitions:
61
70
  // pending → accepted | cancelled | expired
62
71
  status: createSelectField({