@cosmicdrift/kumiko-bundled-features 0.340.0 → 0.342.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/dist/audit/constants.d.ts +3 -0
  2. package/dist/audit/constants.js +3 -0
  3. package/dist/audit/feature.js +34 -7
  4. package/dist/audit/run-escape-hatch-retention.d.ts +10 -0
  5. package/dist/audit/run-escape-hatch-retention.js +27 -0
  6. package/dist/auth-email-password/bootstrap.d.ts +80 -0
  7. package/dist/auth-email-password/bootstrap.js +170 -0
  8. package/dist/auth-email-password/constants.d.ts +2 -0
  9. package/dist/auth-email-password/constants.js +5 -0
  10. package/dist/auth-email-password/errors.d.ts +1 -0
  11. package/dist/auth-email-password/errors.js +6 -0
  12. package/dist/auth-email-password/feature.js +2 -1
  13. package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +9 -1
  14. package/dist/auth-email-password/handlers/invite-accept.write.js +8 -0
  15. package/dist/auth-email-password/handlers/invite-create.write.d.ts +11 -0
  16. package/dist/auth-email-password/handlers/invite-create.write.js +134 -87
  17. package/dist/auth-email-password/handlers/invite-signup-complete.write.js +5 -1
  18. package/dist/auth-email-password/handlers/signup-request.write.js +1 -0
  19. package/dist/auth-email-password/handlers/token-request-handler.js +1 -0
  20. package/dist/auth-email-password/index.d.ts +2 -0
  21. package/dist/auth-email-password/index.js +1 -0
  22. package/dist/auth-email-password/invite-token-store.d.ts +3 -0
  23. package/dist/auth-email-password/invite-token-store.js +5 -0
  24. package/dist/auth-email-password/magic-link-mail.js +1 -0
  25. package/dist/auth-email-password/web/auth-form-primitives.d.ts +7 -1
  26. package/dist/auth-email-password/web/auth-form-primitives.js +5 -5
  27. package/dist/auth-email-password/web/confirm-account-unlock-screen.js +3 -3
  28. package/dist/auth-email-password/web/forgot-password-screen.js +2 -2
  29. package/dist/auth-email-password/web/invite-accept-screen.js +9 -9
  30. package/dist/auth-email-password/web/login-screen.js +8 -8
  31. package/dist/auth-email-password/web/request-account-unlock-screen.js +2 -2
  32. package/dist/auth-email-password/web/reset-password-screen.js +3 -3
  33. package/dist/auth-email-password/web/session-bootstrap-error.js +3 -3
  34. package/dist/auth-email-password/web/signup-complete-screen.js +3 -3
  35. package/dist/auth-email-password/web/signup-screen.js +2 -2
  36. package/dist/auth-email-password/web/verify-email-screen.js +3 -3
  37. package/dist/auth-mfa/config.d.ts +1 -1
  38. package/dist/auth-mfa/config.js +8 -3
  39. package/dist/auth-mfa/feature.d.ts +2 -0
  40. package/dist/auth-mfa/feature.js +2 -1
  41. package/dist/auth-mfa/mfa-status-checker.d.ts +2 -0
  42. package/dist/auth-mfa/mfa-status-checker.js +1 -1
  43. package/dist/auth-mfa/web/mfa-setup-preauth-screen.js +5 -5
  44. package/dist/auth-mfa/web/mfa-verify-screen.js +3 -3
  45. package/dist/billing-foundation/consumer-protection/issue-confirmation.js +1 -0
  46. package/dist/billing-foundation/consumer-protection/termination-mail.js +1 -0
  47. package/dist/billing-foundation/consumer-protection/termination-notify.js +1 -0
  48. package/dist/billing-foundation/consumer-protection/termination-pages.d.ts +8 -1
  49. package/dist/billing-foundation/consumer-protection/termination-pages.js +59 -32
  50. package/dist/billing-foundation/consumer-protection/termination-request.js +2 -0
  51. package/dist/billing-foundation/consumer-protection/termination-texts.d.ts +1 -0
  52. package/dist/billing-foundation/consumer-protection/termination-texts.js +3 -1
  53. package/dist/channel-discord/discord-channel.d.ts +4 -0
  54. package/dist/channel-discord/discord-channel.js +4 -0
  55. package/dist/channel-discord/feature.js +7 -2
  56. package/dist/channel-discord/index.d.ts +1 -1
  57. package/dist/channel-discord/index.js +1 -1
  58. package/dist/channel-email/email-channel.js +8 -1
  59. package/dist/channel-email/pii-guard.js +6 -5
  60. package/dist/channel-email/smtp-transport.js +22 -1
  61. package/dist/channel-email/types.d.ts +1 -0
  62. package/dist/channel-slack/feature.js +4 -2
  63. package/dist/channel-slack/index.d.ts +1 -1
  64. package/dist/channel-slack/index.js +1 -1
  65. package/dist/channel-slack/slack-channel.d.ts +4 -0
  66. package/dist/channel-slack/slack-channel.js +4 -0
  67. package/dist/channel-teams/feature.js +4 -2
  68. package/dist/channel-teams/index.d.ts +1 -1
  69. package/dist/channel-teams/index.js +1 -1
  70. package/dist/channel-teams/teams-channel.d.ts +4 -0
  71. package/dist/channel-teams/teams-channel.js +4 -0
  72. package/dist/channel-telegram/feature.js +4 -2
  73. package/dist/channel-telegram/index.d.ts +1 -1
  74. package/dist/channel-telegram/index.js +1 -1
  75. package/dist/channel-telegram/telegram-channel.d.ts +8 -0
  76. package/dist/channel-telegram/telegram-channel.js +28 -12
  77. package/dist/delivery/chat-webhook-channel.d.ts +5 -1
  78. package/dist/delivery/chat-webhook-channel.js +10 -5
  79. package/dist/delivery/chat-webhook-sender.d.ts +6 -2
  80. package/dist/delivery/chat-webhook-sender.js +13 -8
  81. package/dist/delivery/delivery-service.d.ts +4 -3
  82. package/dist/delivery/delivery-service.js +53 -33
  83. package/dist/delivery/index.d.ts +2 -2
  84. package/dist/delivery/index.js +2 -2
  85. package/dist/delivery/jobs.js +3 -1
  86. package/dist/delivery/types.d.ts +5 -3
  87. package/dist/legal-pages/feature.d.ts +3 -15
  88. package/dist/legal-pages/feature.js +20 -50
  89. package/dist/legal-pages/lib/boot-check.d.ts +10 -0
  90. package/dist/legal-pages/lib/boot-check.js +33 -0
  91. package/dist/legal-pages/lib/validate-routes.d.ts +2 -0
  92. package/dist/legal-pages/lib/validate-routes.js +17 -0
  93. package/dist/page-render/index.d.ts +2 -1
  94. package/dist/page-render/index.js +1 -0
  95. package/dist/page-render/layout.d.ts +7 -0
  96. package/dist/page-render/layout.js +5 -0
  97. package/dist/page-render/trailing-slash.d.ts +2 -0
  98. package/dist/page-render/trailing-slash.js +15 -0
  99. package/dist/renderer-simple/feature.js +1 -0
  100. package/dist/renderer-simple/index.d.ts +1 -1
  101. package/dist/renderer-simple/index.js +1 -1
  102. package/dist/renderer-simple/simple-renderer.d.ts +12 -5
  103. package/dist/renderer-simple/simple-renderer.js +66 -12
  104. package/dist/secrets/announce-secret-change.d.ts +8 -0
  105. package/dist/secrets/announce-secret-change.js +18 -0
  106. package/dist/secrets/feature.js +1 -1
  107. package/dist/secrets/handlers/delete.write.js +2 -0
  108. package/dist/secrets/handlers/set.write.js +6 -1
  109. package/dist/secrets/i18n.d.ts +4 -0
  110. package/dist/secrets/i18n.js +4 -0
  111. package/dist/secrets/secrets-context.d.ts +2 -0
  112. package/dist/secrets/secrets-context.js +3 -0
  113. package/dist/secrets/write-gate.d.ts +5 -1
  114. package/dist/secrets/write-gate.js +24 -1
  115. package/dist/tenant/constants.d.ts +1 -0
  116. package/dist/tenant/constants.js +1 -0
  117. package/dist/tenant/handlers/add-member.write.js +14 -3
  118. package/dist/tenant/handlers/invitations.query.d.ts +8 -0
  119. package/dist/tenant/handlers/invitations.query.js +15 -1
  120. package/dist/tenant/index.d.ts +1 -0
  121. package/dist/tenant/index.js +1 -0
  122. package/dist/tenant/invitation-table.d.ts +2 -0
  123. package/dist/tenant/invitation-table.js +10 -1
  124. package/dist/tenant-lifecycle/lifecycle-gate.d.ts +1 -0
  125. package/dist/tenant-lifecycle/lifecycle-gate.js +2 -0
  126. package/dist/tenant-lifecycle/run-tenant-destroy.js +2 -2
  127. package/dist/tier-engine/feature.js +83 -14
  128. package/dist/tier-engine/handlers/set-tenant-tier.write.d.ts +3 -3
  129. package/dist/tier-engine/handlers/set-tenant-tier.write.js +17 -10
  130. package/dist/user/invited-global-roles.d.ts +14 -0
  131. package/dist/user/invited-global-roles.js +35 -0
  132. package/dist/user-data-rights/feature.js +3 -0
  133. package/dist/waitlist/constants.d.ts +33 -0
  134. package/dist/waitlist/constants.js +35 -0
  135. package/dist/waitlist/entity.d.ts +433 -0
  136. package/dist/waitlist/entity.js +64 -0
  137. package/dist/waitlist/feature.d.ts +3 -0
  138. package/dist/waitlist/feature.js +38 -0
  139. package/dist/waitlist/handlers/invite.write.d.ts +9 -0
  140. package/dist/waitlist/handlers/invite.write.js +119 -0
  141. package/dist/waitlist/handlers/list.query.d.ts +1 -0
  142. package/dist/waitlist/handlers/list.query.js +10 -0
  143. package/dist/waitlist/handlers/reject.write.d.ts +7 -0
  144. package/dist/waitlist/handlers/reject.write.js +80 -0
  145. package/dist/waitlist/handlers/submit.write.d.ts +13 -0
  146. package/dist/waitlist/handlers/submit.write.js +112 -0
  147. package/dist/waitlist/i18n.d.ts +6 -0
  148. package/dist/waitlist/i18n.js +31 -0
  149. package/dist/waitlist/index.d.ts +5 -0
  150. package/dist/waitlist/index.js +4 -0
  151. package/dist/waitlist/lib.d.ts +10 -0
  152. package/dist/waitlist/lib.js +24 -0
  153. package/dist/waitlist/mail.d.ts +15 -0
  154. package/dist/waitlist/mail.js +73 -0
  155. package/dist/waitlist/options.d.ts +30 -0
  156. package/dist/waitlist/options.js +8 -0
  157. package/dist/waitlist/payloads.d.ts +14 -0
  158. package/dist/waitlist/payloads.js +47 -0
  159. package/dist/waitlist/screens.d.ts +2 -0
  160. package/dist/waitlist/screens.js +42 -0
  161. package/dist/waitlist/user-data-hooks.d.ts +3 -0
  162. package/dist/waitlist/user-data-hooks.js +71 -0
  163. package/package.json +17 -9
  164. package/src/auth-email-password/changes.json +30 -0
  165. package/src/auth-mfa/changes.json +6 -0
  166. package/src/billing-foundation/changes.json +6 -0
  167. package/src/channel-email/changes.json +6 -0
  168. package/src/channel-telegram/changes.json +9 -0
  169. package/src/delivery/changes.json +7 -0
  170. package/src/files/changes.json +13 -1
  171. package/src/renderer-simple/changes.json +6 -0
  172. package/src/secrets/changes.json +12 -0
  173. package/src/tenant/changes.json +6 -0
  174. package/src/waitlist/changes.json +23 -0
@@ -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({
@@ -4,6 +4,7 @@ export type TenantLifecycleGate = {
4
4
  readonly status: string;
5
5
  readonly gracePeriodEnd: string | null;
6
6
  };
7
+ export declare const TEARDOWN_GATE_SETTLE_MS = 60000;
7
8
  /** @internal exported for the bounded-cache test */
8
9
  export declare const GATE_CACHE_MAX_ENTRIES = 5000;
9
10
  export declare function invalidateTenantLifecycleGate(tenantId: TenantId): void;
@@ -15,6 +15,8 @@ import { tenantTable } from "../tenant/index.js";
15
15
  // (cached as null) must not grow the Map without bound: past the cap, expired
16
16
  // entries are swept and, if still full, the oldest-inserted entries are evicted.
17
17
  const GATE_TTL_MS = 3000;
18
+ // Covers other pods' GATE_TTL_MS plus requests still in flight when the grace period (0 under a compliance override) ends.
19
+ export const TEARDOWN_GATE_SETTLE_MS = 60_000;
18
20
  /** @internal exported for the bounded-cache test */
19
21
  export const GATE_CACHE_MAX_ENTRIES = 5000;
20
22
  const gateCache = new Map();
@@ -6,7 +6,7 @@ import { append, loadAggregate, VersionConflictError, } from "@cosmicdrift/kumik
6
6
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
7
7
  import { tenantEntity, tenantTable } from "../tenant/index.js";
8
8
  import { TENANT_AGGREGATE_TYPE, TENANT_DESTRUCTION_COMPLETED_EVENT_QN, TENANT_DESTRUCTION_FAILED_EVENT_QN, TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT, TENANT_DESTRUCTION_STARTED_EVENT_SHORT, } from "./constants.js";
9
- import { invalidateTenantLifecycleGate } from "./lifecycle-gate.js";
9
+ import { invalidateTenantLifecycleGate, TEARDOWN_GATE_SETTLE_MS } from "./lifecycle-gate.js";
10
10
  import { isDestructionPipelineComplete, pickNextStage, } from "./stages.js";
11
11
  const tenantCrud = createEventStoreExecutor(tenantTable, tenantEntity, { entityName: "tenant" });
12
12
  const FEATURE_PREFIX = "tenant-lifecycle:event:";
@@ -173,7 +173,7 @@ export async function runTenantDestructionSweep(args) {
173
173
  const now = args.now ?? T.Now.instant();
174
174
  const due = await selectMany(args.db, tenantTable, {
175
175
  status: "destroyRequested",
176
- gracePeriodEnd: { lte: now },
176
+ gracePeriodEnd: { lte: now.subtract({ milliseconds: TEARDOWN_GATE_SETTLE_MS }) },
177
177
  });
178
178
  let triggered = 0;
179
179
  for (const row of due) {