@cosmicdrift/kumiko-samples 0.339.0 → 0.341.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +6 -1
  3. package/packages/bundled-features/src/auth-email-password/bootstrap.ts +309 -0
  4. package/packages/bundled-features/src/auth-email-password/changes.json +18 -0
  5. package/packages/bundled-features/src/auth-email-password/constants.ts +5 -0
  6. package/packages/bundled-features/src/auth-email-password/errors.ts +9 -0
  7. package/packages/bundled-features/src/auth-email-password/feature.ts +2 -0
  8. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +13 -1
  9. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +12 -0
  10. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +210 -113
  11. package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +6 -1
  12. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +1 -0
  13. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +1 -0
  14. package/packages/bundled-features/src/auth-email-password/index.ts +13 -0
  15. package/packages/bundled-features/src/auth-email-password/invite-token-store.ts +6 -0
  16. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +1 -0
  17. package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
  18. package/packages/bundled-features/src/auth-mfa/config.ts +12 -3
  19. package/packages/bundled-features/src/auth-mfa/feature.ts +7 -2
  20. package/packages/bundled-features/src/auth-mfa/mfa-status-checker.ts +2 -1
  21. package/packages/bundled-features/src/billing-foundation/changes.json +13 -0
  22. package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +1 -0
  23. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +1 -0
  24. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-notify.ts +1 -0
  25. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +83 -34
  26. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +2 -0
  27. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +4 -1
  28. package/packages/bundled-features/src/billing-foundation/projection.ts +35 -41
  29. package/packages/bundled-features/src/channel-discord/discord-channel.ts +5 -0
  30. package/packages/bundled-features/src/channel-discord/feature.ts +16 -1
  31. package/packages/bundled-features/src/channel-discord/index.ts +7 -1
  32. package/packages/bundled-features/src/channel-email/email-channel.ts +1 -0
  33. package/packages/bundled-features/src/channel-slack/feature.ts +12 -1
  34. package/packages/bundled-features/src/channel-slack/index.ts +6 -1
  35. package/packages/bundled-features/src/channel-slack/slack-channel.ts +5 -0
  36. package/packages/bundled-features/src/channel-teams/feature.ts +12 -1
  37. package/packages/bundled-features/src/channel-teams/index.ts +6 -1
  38. package/packages/bundled-features/src/channel-teams/teams-channel.ts +5 -0
  39. package/packages/bundled-features/src/channel-telegram/changes.json +9 -0
  40. package/packages/bundled-features/src/channel-telegram/feature.ts +8 -2
  41. package/packages/bundled-features/src/channel-telegram/index.ts +8 -1
  42. package/packages/bundled-features/src/channel-telegram/telegram-channel.ts +39 -11
  43. package/packages/bundled-features/src/delivery/changes.json +7 -0
  44. package/packages/bundled-features/src/delivery/chat-webhook-channel.ts +18 -5
  45. package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +19 -10
  46. package/packages/bundled-features/src/delivery/delivery-service.ts +78 -32
  47. package/packages/bundled-features/src/delivery/index.ts +4 -0
  48. package/packages/bundled-features/src/delivery/jobs.ts +3 -1
  49. package/packages/bundled-features/src/delivery/types.ts +10 -4
  50. package/packages/bundled-features/src/files/changes.json +13 -1
  51. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +7 -0
  52. package/packages/bundled-features/src/inbound-mail-foundation/projection.ts +57 -58
  53. package/packages/bundled-features/src/legal-pages/README.md +26 -3
  54. package/packages/bundled-features/src/legal-pages/feature.ts +27 -85
  55. package/packages/bundled-features/src/legal-pages/lib/boot-check.ts +57 -0
  56. package/packages/bundled-features/src/legal-pages/lib/validate-routes.ts +21 -0
  57. package/packages/bundled-features/src/page-render/index.ts +2 -0
  58. package/packages/bundled-features/src/page-render/layout.ts +17 -0
  59. package/packages/bundled-features/src/page-render/trailing-slash.ts +15 -0
  60. package/packages/bundled-features/src/renderer-simple/changes.json +6 -0
  61. package/packages/bundled-features/src/renderer-simple/feature.ts +1 -0
  62. package/packages/bundled-features/src/renderer-simple/index.ts +6 -1
  63. package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +103 -19
  64. package/packages/bundled-features/src/secrets/announce-secret-change.ts +30 -0
  65. package/packages/bundled-features/src/secrets/changes.json +12 -0
  66. package/packages/bundled-features/src/secrets/feature.ts +1 -1
  67. package/packages/bundled-features/src/secrets/handlers/delete.write.ts +2 -0
  68. package/packages/bundled-features/src/secrets/handlers/set.write.ts +5 -1
  69. package/packages/bundled-features/src/secrets/i18n.ts +4 -0
  70. package/packages/bundled-features/src/secrets/secrets-context.ts +9 -0
  71. package/packages/bundled-features/src/secrets/write-gate.ts +41 -0
  72. package/packages/bundled-features/src/tenant/changes.json +6 -0
  73. package/packages/bundled-features/src/tenant/constants.ts +1 -0
  74. package/packages/bundled-features/src/tenant/handlers/add-member.write.ts +27 -3
  75. package/packages/bundled-features/src/tenant/handlers/invitations.query.ts +15 -1
  76. package/packages/bundled-features/src/tenant/index.ts +1 -0
  77. package/packages/bundled-features/src/tenant/invitation-table.ts +10 -0
  78. package/packages/bundled-features/src/tenant-lifecycle/lifecycle-gate.ts +2 -0
  79. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +2 -2
  80. package/packages/bundled-features/src/tier-engine/feature.ts +92 -14
  81. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +18 -13
  82. package/packages/bundled-features/src/user/changes.json +13 -6
  83. package/packages/bundled-features/src/user/handlers/update-roles.ts +7 -23
  84. package/packages/bundled-features/src/user/invited-global-roles.ts +58 -0
  85. package/packages/bundled-features/src/user-data-rights/feature.ts +3 -0
  86. package/packages/bundled-features/src/waitlist/changes.json +17 -0
  87. package/packages/bundled-features/src/waitlist/constants.ts +44 -0
  88. package/packages/bundled-features/src/waitlist/entity.ts +76 -0
  89. package/packages/bundled-features/src/waitlist/feature.ts +50 -0
  90. package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +175 -0
  91. package/packages/bundled-features/src/waitlist/handlers/list.query.ts +12 -0
  92. package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +120 -0
  93. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +163 -0
  94. package/packages/bundled-features/src/waitlist/i18n.ts +33 -0
  95. package/packages/bundled-features/src/waitlist/index.ts +15 -0
  96. package/packages/bundled-features/src/waitlist/lib.ts +36 -0
  97. package/packages/bundled-features/src/waitlist/mail.ts +114 -0
  98. package/packages/bundled-features/src/waitlist/options.ts +41 -0
  99. package/packages/bundled-features/src/waitlist/payloads.ts +55 -0
  100. package/packages/bundled-features/src/waitlist/screens.ts +43 -0
  101. package/packages/bundled-features/src/waitlist/user-data-hooks.ts +82 -0
  102. package/packages/bundled-features/src/workflow-runner/changes.json +7 -0
  103. package/packages/bundled-features/src/workflow-runner/db/queries/due-runs.ts +10 -13
  104. package/packages/bundled-features/src/workflow-runner/feature.ts +4 -11
  105. package/samples/apps/use-all-bundled/feature-manifest.json +36 -5
  106. package/samples/apps/use-all-bundled/kumiko/migrations/0033_waitlist.rebuild.json +4 -0
  107. package/samples/apps/use-all-bundled/schema/generate.ts +5 -0
  108. package/samples/apps/use-all-bundled/schema/schema.generated.ts +3 -0
  109. package/samples/apps/use-all-bundled/src/run-config.ts +2 -0
  110. package/samples/recipes/chat-channels/README.md +2 -0
  111. package/samples/recipes/managed-config/README.md +1 -1
  112. package/samples/recipes/rate-limiting/README.md +21 -0
  113. package/packages/bundled-features/src/billing-foundation/db/queries/payment-projection.ts +0 -19
  114. package/packages/bundled-features/src/billing-foundation/db/queries/subscription-projection.ts +0 -15
  115. package/packages/bundled-features/src/inbound-mail-foundation/db/queries/inbound-projections.ts +0 -38
@@ -6,13 +6,24 @@ export type MailBranding = {
6
6
  readonly productName?: string;
7
7
  /** Hex color (#rgb, #rrggbb, #rrggbbaa); used for buttons and the header accent. */
8
8
  readonly primaryColor?: string;
9
- /** Absolute http(s) URL; anything else is dropped. */
9
+ /** Absolute http(s) URL; anything else is dropped. Mutually exclusive with `logoPath`. */
10
10
  readonly logoUrl?: string;
11
- readonly footerText?: string;
12
- /** Absolute http(s) URLs only; other entries are dropped. */
13
- readonly footerLinks?: readonly { readonly label: string; readonly url: string }[];
11
+ /** Absolute path on `baseUrl` (e.g. "/logo.png"); use PNG/JPEG, mail clients block SVG. */
12
+ readonly logoPath?: string;
13
+ /** Absolute http(s) origin that `logoPath` is resolved against; same base as `auth.mail.baseUrl`. */
14
+ readonly baseUrl?: string;
15
+ /** Locale tried after the exact and language-part match when a localized field has no entry. */
16
+ readonly defaultLocale?: string;
17
+ readonly footerText?: LocalizedText;
18
+ /** Absolute http(s) URLs only; string URLs that are not are dropped, localized ones fail at boot. */
19
+ readonly footerLinks?: readonly {
20
+ readonly label: LocalizedText;
21
+ readonly url: LocalizedText;
22
+ }[];
14
23
  };
15
24
 
25
+ export type LocalizedText = string | Readonly<Record<string, string>>;
26
+
16
27
  const DEFAULT_PRIMARY_COLOR = "#2563eb";
17
28
  const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
18
29
 
@@ -25,14 +36,79 @@ function isHttpUrl(value: string): boolean {
25
36
  }
26
37
  }
27
38
 
39
+ function languagePart(locale: string): string {
40
+ return locale.split("-")[0] ?? locale;
41
+ }
42
+
43
+ function pickByLocale(
44
+ entries: Readonly<Record<string, string>>,
45
+ locale: string,
46
+ ): string | undefined {
47
+ // Own keys only: a locale like "constructor" must not hit Object.prototype.
48
+ const ownEntry = (key: string): string | undefined =>
49
+ Object.hasOwn(entries, key) ? entries[key] : undefined;
50
+ return ownEntry(locale) ?? ownEntry(languagePart(locale));
51
+ }
52
+
53
+ function resolveLocalized(
54
+ value: LocalizedText | undefined,
55
+ locale: string | undefined,
56
+ defaultLocale: string | undefined,
57
+ ): string | undefined {
58
+ if (value === undefined || typeof value === "string") return value;
59
+ return (
60
+ (locale !== undefined ? pickByLocale(value, locale) : undefined) ??
61
+ (defaultLocale !== undefined ? pickByLocale(value, defaultLocale) : undefined) ??
62
+ Object.values(value)[0]
63
+ );
64
+ }
65
+
66
+ function assertLocalizedUrlsAreHttp(branding: MailBranding): void {
67
+ for (const link of branding.footerLinks ?? []) {
68
+ if (typeof link.url === "string") continue;
69
+ for (const [locale, url] of Object.entries(link.url)) {
70
+ if (!isHttpUrl(url)) {
71
+ throw new Error(
72
+ `createSimpleRenderer: footerLinks url for locale "${locale}" must be an absolute http(s) URL`,
73
+ );
74
+ }
75
+ }
76
+ }
77
+ }
78
+
79
+ function resolveLogoUrl(branding: MailBranding | undefined): string | undefined {
80
+ if (!branding) return undefined;
81
+ const { logoUrl, logoPath, baseUrl } = branding;
82
+ if (logoPath === undefined) {
83
+ return logoUrl && isHttpUrl(logoUrl) ? logoUrl : undefined;
84
+ }
85
+ if (logoUrl !== undefined) {
86
+ throw new Error("createSimpleRenderer: logoPath and logoUrl are mutually exclusive");
87
+ }
88
+ if (baseUrl === undefined || !isHttpUrl(baseUrl)) {
89
+ throw new Error("createSimpleRenderer: logoPath requires an absolute http(s) baseUrl");
90
+ }
91
+ if (!/^\/(?![/\\])/.test(logoPath) || logoPath.includes("\\")) {
92
+ throw new Error('createSimpleRenderer: logoPath must start with a single "/"');
93
+ }
94
+ const resolved = new URL(logoPath, baseUrl);
95
+ if (resolved.origin !== new URL(baseUrl).origin) {
96
+ throw new Error("createSimpleRenderer: logoPath must stay on the baseUrl origin");
97
+ }
98
+ return resolved.href;
99
+ }
100
+
28
101
  function resolvePrimaryColor(branding: MailBranding | undefined): string {
29
102
  const color = branding?.primaryColor;
30
103
  return color && HEX_COLOR_PATTERN.test(color) ? color : DEFAULT_PRIMARY_COLOR;
31
104
  }
32
105
 
33
- function renderBrandingHeader(branding: MailBranding | undefined, primaryColor: string): string {
106
+ function renderBrandingHeader(
107
+ branding: MailBranding | undefined,
108
+ primaryColor: string,
109
+ logoUrl: string | undefined,
110
+ ): string {
34
111
  if (!branding) return "";
35
- const logoUrl = branding.logoUrl && isHttpUrl(branding.logoUrl) ? branding.logoUrl : undefined;
36
112
  if (!logoUrl && !branding.productName) return "";
37
113
  const logo = logoUrl
38
114
  ? `<img src="${escapeHtmlAttr(logoUrl)}" alt="${escapeHtmlAttr(branding.productName ?? "")}" style="display:block;max-height:40px;max-width:200px;border:0" />`
@@ -40,18 +116,24 @@ function renderBrandingHeader(branding: MailBranding | undefined, primaryColor:
40
116
  return `<div style="margin:0 0 24px;padding:0 0 16px;border-bottom:3px solid ${escapeHtmlAttr(primaryColor)}">${logo}</div>`;
41
117
  }
42
118
 
43
- function renderBrandingFooter(branding: MailBranding | undefined): string {
119
+ function renderBrandingFooter(
120
+ branding: MailBranding | undefined,
121
+ locale: string | undefined,
122
+ ): string {
44
123
  if (!branding) return "";
45
- const links = (branding.footerLinks ?? [])
46
- .filter((link) => isHttpUrl(link.url))
47
- .map(
48
- (link) =>
49
- `<a href="${escapeHtmlAttr(link.url)}" style="color:#999">${escapeHtml(link.label)}</a>`,
50
- );
51
- const footerPartsHtml = [
52
- branding.footerText ? escapeHtml(branding.footerText) : "",
53
- links.join(" · "),
54
- ].filter((part) => part !== "");
124
+ const pick = (value: LocalizedText | undefined): string | undefined =>
125
+ resolveLocalized(value, locale, branding.defaultLocale);
126
+ const links = (branding.footerLinks ?? []).flatMap((link) => {
127
+ const url = pick(link.url);
128
+ if (url === undefined || !isHttpUrl(url)) return [];
129
+ return [
130
+ `<a href="${escapeHtmlAttr(url)}" style="color:#999">${escapeHtml(pick(link.label) ?? "")}</a>`,
131
+ ];
132
+ });
133
+ const footerText = pick(branding.footerText);
134
+ const footerPartsHtml = [footerText ? escapeHtml(footerText) : "", links.join(" · ")].filter(
135
+ (part) => part !== "",
136
+ );
55
137
  if (footerPartsHtml.length === 0) return "";
56
138
  const footerHtml = footerPartsHtml.join("<br />");
57
139
  return `<p style="margin:16px 0 0;color:#999;font-size:12px">${footerHtml}</p>`;
@@ -85,6 +167,8 @@ function renderSection(section: Section, primaryColor: string): string {
85
167
  // No external dependencies, no template engine — just string concatenation.
86
168
  export function createSimpleRenderer(branding?: MailBranding): NotificationRenderer {
87
169
  const primaryColor = resolvePrimaryColor(branding);
170
+ const logoUrl = resolveLogoUrl(branding);
171
+ if (branding) assertLocalizedUrlsAreHttp(branding);
88
172
  return {
89
173
  name: "simple",
90
174
 
@@ -98,7 +182,7 @@ export function createSimpleRenderer(branding?: MailBranding): NotificationRende
98
182
  const parts: string[] = [];
99
183
  parts.push('<!DOCTYPE html><html><body style="margin:0;padding:0;font-family:sans-serif">');
100
184
  parts.push('<div style="max-width:600px;margin:0 auto;padding:24px">');
101
- parts.push(renderBrandingHeader(branding, primaryColor));
185
+ parts.push(renderBrandingHeader(branding, primaryColor, logoUrl));
102
186
 
103
187
  if (header) {
104
188
  parts.push(
@@ -118,7 +202,7 @@ export function createSimpleRenderer(branding?: MailBranding): NotificationRende
118
202
  );
119
203
  }
120
204
 
121
- parts.push(renderBrandingFooter(branding));
205
+ parts.push(renderBrandingFooter(branding, input.locale));
122
206
  parts.push("</div></body></html>");
123
207
  return parts.join("");
124
208
  },
@@ -0,0 +1,30 @@
1
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
2
+ import {
3
+ CACHE_SYNC_TOPICS,
4
+ type CacheSyncBus,
5
+ type TenantConfigSyncMessage,
6
+ } from "@cosmicdrift/kumiko-framework/redis";
7
+
8
+ type AnnouncingContext = {
9
+ readonly cacheSync?: CacheSyncBus;
10
+ readonly scheduleAfterCommit?: (hook: () => Promise<void>) => void;
11
+ };
12
+
13
+ // Secret writes bypass config:write:*, yet provider caches (e.g. the S3 secret
14
+ // key) depend on them: announce on the tenant-config topic, after commit so a
15
+ // replica reloading on the message sees the new value.
16
+ export function announceSecretChange(
17
+ ctx: AnnouncingContext,
18
+ tenantId: TenantId,
19
+ key: string,
20
+ ): void {
21
+ const bus = ctx.cacheSync;
22
+ // skip: no bus wired (single-process setup), nothing to announce
23
+ if (!bus) return;
24
+ const message: TenantConfigSyncMessage = { tenantId, key };
25
+ const publish = async (): Promise<void> => {
26
+ bus.publish(CACHE_SYNC_TOPICS.tenantConfig, message);
27
+ };
28
+ if (ctx.scheduleAfterCommit) ctx.scheduleAfterCommit(publish);
29
+ else bus.publish(CACHE_SYNC_TOPICS.tenantConfig, message);
30
+ }
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.341.0",
4
+ "type": "improvement",
5
+ "title": "Chat channel secret values are validated on write",
6
+ "detail": "`r.secret` and `r.secretNamespace` accept a `valueSchema`. `secrets:write:set` checks the value against it after the key and role checks and answers a failure with a 400 validation error on field `value` (`secrets.errors.invalidValue`); the response never contains the value or the schema's issues. `secrets:write:delete` is unaffected.\nThe chat channels use it: Slack, Discord and Teams webhook secrets must be URLs that pass the channel's host allowlist (including the Discord `/api/webhooks/` path), and the Telegram bot token must have the `<bot id>:<secret>` shape. The allowlist comes from the same options as the channel, so `createChannelSlackFeature({ allowedHosts })` also governs what can be stored.\n`channel-slack`, `channel-discord`, `channel-teams` and `channel-telegram` now export their secret keys (`SLACK_SECRET_KEYS`, `DISCORD_SECRET_KEYS`, `TEAMS_SECRET_KEYS`, `TELEGRAM_SECRET_KEYS`), allowlist constants and `isTelegramChatId` / `isTelegramBotToken`. `delivery` exports `checkChatWebhookTarget`, `chatWebhookUrlSchema` and `resolveChatWebhookTarget`, so apps can validate an address or URL when a user creates a channel.\nExisting invalid secrets stay stored; only new writes are checked. Writes through `ctx.secrets.set` in feature code run through the same check."
7
+ },
8
+ {
9
+ "version": "0.341.0",
10
+ "type": "breaking",
11
+ "title": "ctx.secrets.set validates against the declared valueSchema",
12
+ "migration": "Code that calls createSecretsContext({ db, masterKeyProvider }) must also pass registry (the booted Registry). Programmatic ctx.secrets.set calls with a value that fails the key's valueSchema now throw a ValidationError instead of storing it."
13
+ },
2
14
  {
3
15
  "version": "0.337.0",
4
16
  "type": "breaking",
@@ -76,7 +76,7 @@ export function requireSecretsContext(
76
76
  throw new InternalError({
77
77
  message:
78
78
  `[${handlerName}] ctx.secrets missing — pass ` +
79
- "createSecretsContext({db, masterKeyProvider}) via extraContext.secrets at boot.",
79
+ "createSecretsContext({db, masterKeyProvider, registry}) via extraContext.secrets at boot.",
80
80
  });
81
81
  }
82
82
  const raw = ctx.secrets;
@@ -1,6 +1,7 @@
1
1
  import { type AccessRule, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { failNotFound } from "@cosmicdrift/kumiko-framework/errors";
3
3
  import * as z from "zod";
4
+ import { announceSecretChange } from "../announce-secret-change.js";
4
5
  import { DEFAULT_SECRETS_ACCESS } from "../constants.js";
5
6
  import { requireSecretsContext } from "../feature.js";
6
7
  import { checkSecretKeyWrite } from "../write-gate.js";
@@ -23,6 +24,7 @@ export function createDeleteHandler(access: AccessRule = DEFAULT_SECRETS_ACCESS)
23
24
  deletedBy: event.user.id,
24
25
  });
25
26
  if (!removed) return failNotFound("tenant-secret", event.payload.key);
27
+ announceSecretChange(ctx, event.user.tenantId, event.payload.key);
26
28
  return { isSuccess: true, data: { key: event.payload.key } };
27
29
  },
28
30
  });
@@ -1,8 +1,9 @@
1
1
  import { type AccessRule, 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
 
7
8
  export function createSetHandler(access: AccessRule = DEFAULT_SECRETS_ACCESS) {
8
9
  return defineWriteHandler({
@@ -32,6 +33,8 @@ export function createSetHandler(access: AccessRule = DEFAULT_SECRETS_ACCESS) {
32
33
  // framework default unless the caller sent a specific preview.
33
34
  const gate = checkSecretKeyWrite(ctx.registry, event.user.roles, key);
34
35
  if (!gate.ok) return gate.failure;
36
+ const invalidValue = checkSecretValue(ctx.registry, key, value);
37
+ if (invalidValue) return invalidValue;
35
38
  const featureRedact = gate.keyDef?.redact;
36
39
  const redactFn: (v: string) => string = redactedPreview
37
40
  ? () => redactedPreview
@@ -42,6 +45,7 @@ export function createSetHandler(access: AccessRule = DEFAULT_SECRETS_ACCESS) {
42
45
  ...(hint ? { hint } : {}),
43
46
  updatedBy: event.user.id,
44
47
  });
48
+ announceSecretChange(ctx, event.user.tenantId, key);
45
49
 
46
50
  return {
47
51
  isSuccess: true,
@@ -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
  } as const;
@@ -42,6 +42,11 @@ import {
42
42
  tenantSecretEntity,
43
43
  tenantSecretsTable,
44
44
  } from "./table.js";
45
+ import {
46
+ invalidSecretValueError,
47
+ isSecretValueValid,
48
+ type SecretValueSchemaSource,
49
+ } from "./write-gate.js";
45
50
 
46
51
  // Re-export the framework interface so consumers of bundled-features/secrets
47
52
  // don't need to reach into @cosmicdrift/kumiko-framework/secrets separately.
@@ -50,6 +55,9 @@ export type { Secret, SecretsContext } from "@cosmicdrift/kumiko-framework/secre
50
55
  export type SecretsContextOptions = {
51
56
  readonly db: DbConnection;
52
57
  readonly masterKeyProvider: MasterKeyProvider;
58
+ // Source of the declared `valueSchema`s: programmatic `set` enforces the same
59
+ // check as the write handler. Required so a context can't silently skip it.
60
+ readonly registry: SecretValueSchemaSource;
53
61
  // Shared DEK cache. Default: a fresh 5-min TTL cache. Pass in a shared
54
62
  // instance if several features decrypt overlapping secret sets — lowers
55
63
  // provider-call count across the app.
@@ -164,6 +172,7 @@ export function createSecretsContext(opts: SecretsContextOptions): SecretsContex
164
172
 
165
173
  async set(tenantId, keyOrHandle, value, setOpts = {}) {
166
174
  const key = resolveKey(keyOrHandle);
175
+ if (!isSecretValueValid(opts.registry, key, value)) throw invalidSecretValueError();
167
176
  const envelope = await encryptValue(value, masterKeyProvider);
168
177
  const stored: StoredEnvelope = encodeStoredEnvelope(envelope);
169
178
  const metadata: StoredMetadata = {
@@ -6,6 +6,7 @@ import type {
6
6
  import {
7
7
  AccessDeniedError,
8
8
  NotFoundError,
9
+ ValidationError,
9
10
  type WriteFailure,
10
11
  writeFailure,
11
12
  } from "@cosmicdrift/kumiko-framework/errors";
@@ -60,3 +61,43 @@ export function checkSecretKeyWrite(
60
61
  }
61
62
  return { ok: true, keyDef };
62
63
  }
64
+
65
+ // The registry surface the value check needs; lets ctx.secrets validate without a full Registry.
66
+ export type SecretValueSchemaSource = Pick<Registry, "getSecretKey" | "findSecretNamespace">;
67
+
68
+ // Carries neither the value nor the schema's issues.
69
+ export function invalidSecretValueError(): ValidationError {
70
+ return new ValidationError(
71
+ {
72
+ fields: [
73
+ {
74
+ path: "value",
75
+ code: "invalid_secret_value",
76
+ i18nKey: "secrets.errors.invalidValue",
77
+ },
78
+ ],
79
+ },
80
+ { i18nKey: "secrets.errors.invalidValue" },
81
+ );
82
+ }
83
+
84
+ export function isSecretValueValid(
85
+ schemas: SecretValueSchemaSource,
86
+ key: string,
87
+ value: string,
88
+ ): boolean {
89
+ const valueSchema =
90
+ schemas.getSecretKey(key)?.valueSchema ?? schemas.findSecretNamespace(key)?.valueSchema;
91
+ return valueSchema === undefined || valueSchema.safeParse(value).success;
92
+ }
93
+
94
+ // Runs after checkSecretKeyWrite, so a caller without the write role never learns
95
+ // whether a value would have passed.
96
+ export function checkSecretValue(
97
+ registry: SecretValueSchemaSource,
98
+ key: string,
99
+ value: string,
100
+ ): WriteFailure | undefined {
101
+ if (isSecretValueValid(registry, key, value)) return undefined;
102
+ return writeFailure(invalidSecretValueError());
103
+ }
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.341.0",
4
+ "type": "improvement",
5
+ "title": "Harden system role, invitations query, tenant teardown and addMember",
6
+ "detail": "The system role can no longer be minted into a session through a user's global roles. invitations.query returns an explicit field allowlist and exposes globalRoles only to a SystemAdmin. The tenant destruction sweep waits 60 seconds after the grace period ends so the teardown gate can settle. Behaviour change: tenant:write:addMember now adds members to a foreign tenant only from the framework system context; a SystemAdmin request user is limited to their own tenant and gets 403 otherwise."
7
+ },
2
8
  {
3
9
  "version": "0.336.1",
4
10
  "type": "fix",
@@ -75,4 +75,5 @@ export const TenantErrors = {
75
75
  membershipAlreadyExists: "membership_already_exists",
76
76
  lastTenantAdmin: "last_tenant_admin",
77
77
  invitationSuperseded: "invitation_superseded",
78
+ crossTenantMembershipDenied: "cross_tenant_membership_denied",
78
79
  } as const;
@@ -1,7 +1,17 @@
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 {
4
+ defineWriteHandler,
5
+ type SessionUser,
6
+ SYSTEM_ROLE,
7
+ SYSTEM_USER_ID,
8
+ } from "@cosmicdrift/kumiko-framework/engine";
9
+ import {
10
+ AccessDeniedError,
11
+ ConflictError,
12
+ InternalError,
13
+ writeFailure,
14
+ } from "@cosmicdrift/kumiko-framework/errors";
5
15
  import * as z from "zod";
6
16
  import { TenantErrors } from "../constants.js";
7
17
  import { findForbiddenMembershipRole, reservedMembershipRoleError } from "../membership-roles.js";
@@ -11,6 +21,12 @@ const executor = createEventStoreExecutor(tenantMembershipsTable, tenantMembersh
11
21
  entityName: "tenant-membership",
12
22
  });
13
23
 
24
+ // Both halves: test stacks can mint JWTs with the "system" role, but only the
25
+ // framework operator carries SYSTEM_USER_ID.
26
+ function isFrameworkSystemUser(user: SessionUser): boolean {
27
+ return user.id === SYSTEM_USER_ID && user.roles.includes(SYSTEM_ROLE);
28
+ }
29
+
14
30
  export const addMemberWrite = defineWriteHandler({
15
31
  name: "addMember",
16
32
  schema: z.object({
@@ -18,7 +34,7 @@ export const addMemberWrite = defineWriteHandler({
18
34
  tenantId: z.string(),
19
35
  roles: z.array(z.string()).min(1),
20
36
  }),
21
- access: { roles: ["SystemAdmin"] },
37
+ access: { roles: [SYSTEM_ROLE, "SystemAdmin"] },
22
38
  description:
23
39
  "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.",
24
40
  handler: async (event, ctx) => {
@@ -31,6 +47,14 @@ export const addMemberWrite = defineWriteHandler({
31
47
  const db = ctx.systemDb.acknowledgeCrossTenant(
32
48
  "SystemAdmin manages memberships across tenants",
33
49
  );
50
+ if (!isFrameworkSystemUser(event.user) && event.payload.tenantId !== event.user.tenantId) {
51
+ return writeFailure(
52
+ new AccessDeniedError({
53
+ message: "only the system context may add members to another tenant",
54
+ details: { reason: TenantErrors.crossTenantMembershipDenied },
55
+ }),
56
+ );
57
+ }
34
58
  const forbidden = findForbiddenMembershipRole(event.payload.roles);
35
59
  if (forbidden !== undefined) return writeFailure(reservedMembershipRoleError(forbidden));
36
60
  const existing = await fetchOne(db, tenantMembershipsTable, {
@@ -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
 
8
9
  // Bounded, not Promise.all/sequential: each decrypt hits the KMS adapter's
9
10
  // own small dedicated pool (PgKmsAdapter default max: 4). Promise.all fires
@@ -52,7 +53,20 @@ export const invitationsQuery = defineQueryHandler({
52
53
  typeof invitedBy === "string"
53
54
  ? await decryptStoredPii(invitedBy, "invitedBy", "tenant:invitations")
54
55
  : invitedBy;
55
- return { ...row, email: decryptedEmail, invitedBy: decryptedInvitedBy };
56
+ // Explicit allowlist: the raw row also carries blind-index columns and
57
+ // globalRoles, which only a SystemAdmin may see.
58
+ return {
59
+ id: row["id"],
60
+ tenantId: row["tenantId"],
61
+ email: decryptedEmail,
62
+ role: row["role"],
63
+ status: row["status"],
64
+ invitedBy: decryptedInvitedBy,
65
+ expiresAt: row["expiresAt"],
66
+ insertedAt: row["insertedAt"],
67
+ version: row["version"],
68
+ ...(isSystemAdmin(query.user) && { globalRoles: row["globalRoles"] }),
69
+ };
56
70
  });
57
71
  },
58
72
  });
@@ -1,3 +1,4 @@
1
+ export { cancelPendingInvitation } from "./cancel-pending-invitation.js";
1
2
  export { TenantCommandSchemas } from "./command-schemas.js";
2
3
  export {
3
4
  OWNER_INVITE_ROLE_OPTIONS,
@@ -25,10 +25,13 @@
25
25
  import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
26
26
  import {
27
27
  createEntity,
28
+ createMultiSelectField,
28
29
  createSelectField,
29
30
  createTextField,
30
31
  createTimestampField,
31
32
  } from "@cosmicdrift/kumiko-framework/engine";
33
+ // kumiko-lint-ignore cross-feature-import global-role options are owned by the user feature
34
+ import { GLOBAL_ROLE_OPTIONS } from "../user/constants.js";
32
35
 
33
36
  // Status-const-Object damit Handler-Code keine Magic-Strings nutzt.
34
37
  // Bei rename (z.B. "cancelled" → "revoked") fällt jeder caller auf
@@ -67,6 +70,13 @@ export const tenantInvitationEntity = createEntity({
67
70
  personal: false,
68
71
  reason: "technical_reference",
69
72
  }),
73
+ // Global (users.roles) roles granted on accept, on top of the membership
74
+ // role. Only the system-only invite variant sets them; every tenant-admin
75
+ // re-invite resets them to [] so a resend can never carry them over.
76
+ globalRoles: createMultiSelectField({
77
+ options: GLOBAL_ROLE_OPTIONS,
78
+ default: [],
79
+ }),
70
80
  // Lifecycle-State. Default "pending"; transitions:
71
81
  // pending → accepted | cancelled | expired
72
82
  status: createSelectField({
@@ -23,6 +23,8 @@ export type TenantLifecycleGate = {
23
23
  // (cached as null) must not grow the Map without bound: past the cap, expired
24
24
  // entries are swept and, if still full, the oldest-inserted entries are evicted.
25
25
  const GATE_TTL_MS = 3000;
26
+ // Covers other pods' GATE_TTL_MS plus requests still in flight when the grace period (0 under a compliance override) ends.
27
+ export const TEARDOWN_GATE_SETTLE_MS = 60_000;
26
28
  /** @internal exported for the bounded-cache test */
27
29
  export const GATE_CACHE_MAX_ENTRIES = 5000;
28
30
  const gateCache = new Map<
@@ -26,7 +26,7 @@ import {
26
26
  TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT,
27
27
  TENANT_DESTRUCTION_STARTED_EVENT_SHORT,
28
28
  } from "./constants.js";
29
- import { invalidateTenantLifecycleGate } from "./lifecycle-gate.js";
29
+ import { invalidateTenantLifecycleGate, TEARDOWN_GATE_SETTLE_MS } from "./lifecycle-gate.js";
30
30
  import {
31
31
  type DestructionStageCtx,
32
32
  isDestructionPipelineComplete,
@@ -321,7 +321,7 @@ export async function runTenantDestructionSweep(args: {
321
321
  const now = args.now ?? T.Now.instant();
322
322
  const due = await selectMany<{ id: string }>(args.db, tenantTable, {
323
323
  status: "destroyRequested",
324
- gracePeriodEnd: { lte: now },
324
+ gracePeriodEnd: { lte: now.subtract({ milliseconds: TEARDOWN_GATE_SETTLE_MS }) },
325
325
  });
326
326
 
327
327
  let triggered = 0;