@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.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +6 -1
- package/packages/bundled-features/src/auth-email-password/bootstrap.ts +309 -0
- package/packages/bundled-features/src/auth-email-password/changes.json +18 -0
- package/packages/bundled-features/src/auth-email-password/constants.ts +5 -0
- package/packages/bundled-features/src/auth-email-password/errors.ts +9 -0
- package/packages/bundled-features/src/auth-email-password/feature.ts +2 -0
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +13 -1
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +12 -0
- package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +210 -113
- package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +6 -1
- package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/index.ts +13 -0
- package/packages/bundled-features/src/auth-email-password/invite-token-store.ts +6 -0
- package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +1 -0
- package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
- package/packages/bundled-features/src/auth-mfa/config.ts +12 -3
- package/packages/bundled-features/src/auth-mfa/feature.ts +7 -2
- package/packages/bundled-features/src/auth-mfa/mfa-status-checker.ts +2 -1
- package/packages/bundled-features/src/billing-foundation/changes.json +13 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-notify.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +83 -34
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +2 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +4 -1
- package/packages/bundled-features/src/billing-foundation/projection.ts +35 -41
- package/packages/bundled-features/src/channel-discord/discord-channel.ts +5 -0
- package/packages/bundled-features/src/channel-discord/feature.ts +16 -1
- package/packages/bundled-features/src/channel-discord/index.ts +7 -1
- package/packages/bundled-features/src/channel-email/email-channel.ts +1 -0
- package/packages/bundled-features/src/channel-slack/feature.ts +12 -1
- package/packages/bundled-features/src/channel-slack/index.ts +6 -1
- package/packages/bundled-features/src/channel-slack/slack-channel.ts +5 -0
- package/packages/bundled-features/src/channel-teams/feature.ts +12 -1
- package/packages/bundled-features/src/channel-teams/index.ts +6 -1
- package/packages/bundled-features/src/channel-teams/teams-channel.ts +5 -0
- package/packages/bundled-features/src/channel-telegram/changes.json +9 -0
- package/packages/bundled-features/src/channel-telegram/feature.ts +8 -2
- package/packages/bundled-features/src/channel-telegram/index.ts +8 -1
- package/packages/bundled-features/src/channel-telegram/telegram-channel.ts +39 -11
- package/packages/bundled-features/src/delivery/changes.json +7 -0
- package/packages/bundled-features/src/delivery/chat-webhook-channel.ts +18 -5
- package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +19 -10
- package/packages/bundled-features/src/delivery/delivery-service.ts +78 -32
- package/packages/bundled-features/src/delivery/index.ts +4 -0
- package/packages/bundled-features/src/delivery/jobs.ts +3 -1
- package/packages/bundled-features/src/delivery/types.ts +10 -4
- package/packages/bundled-features/src/files/changes.json +13 -1
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +7 -0
- package/packages/bundled-features/src/inbound-mail-foundation/projection.ts +57 -58
- package/packages/bundled-features/src/legal-pages/README.md +26 -3
- package/packages/bundled-features/src/legal-pages/feature.ts +27 -85
- package/packages/bundled-features/src/legal-pages/lib/boot-check.ts +57 -0
- package/packages/bundled-features/src/legal-pages/lib/validate-routes.ts +21 -0
- package/packages/bundled-features/src/page-render/index.ts +2 -0
- package/packages/bundled-features/src/page-render/layout.ts +17 -0
- package/packages/bundled-features/src/page-render/trailing-slash.ts +15 -0
- package/packages/bundled-features/src/renderer-simple/changes.json +6 -0
- package/packages/bundled-features/src/renderer-simple/feature.ts +1 -0
- package/packages/bundled-features/src/renderer-simple/index.ts +6 -1
- package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +103 -19
- package/packages/bundled-features/src/secrets/announce-secret-change.ts +30 -0
- package/packages/bundled-features/src/secrets/changes.json +12 -0
- package/packages/bundled-features/src/secrets/feature.ts +1 -1
- package/packages/bundled-features/src/secrets/handlers/delete.write.ts +2 -0
- package/packages/bundled-features/src/secrets/handlers/set.write.ts +5 -1
- package/packages/bundled-features/src/secrets/i18n.ts +4 -0
- package/packages/bundled-features/src/secrets/secrets-context.ts +9 -0
- package/packages/bundled-features/src/secrets/write-gate.ts +41 -0
- package/packages/bundled-features/src/tenant/changes.json +6 -0
- package/packages/bundled-features/src/tenant/constants.ts +1 -0
- package/packages/bundled-features/src/tenant/handlers/add-member.write.ts +27 -3
- package/packages/bundled-features/src/tenant/handlers/invitations.query.ts +15 -1
- package/packages/bundled-features/src/tenant/index.ts +1 -0
- package/packages/bundled-features/src/tenant/invitation-table.ts +10 -0
- package/packages/bundled-features/src/tenant-lifecycle/lifecycle-gate.ts +2 -0
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +2 -2
- package/packages/bundled-features/src/tier-engine/feature.ts +92 -14
- package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +18 -13
- package/packages/bundled-features/src/user/changes.json +13 -6
- package/packages/bundled-features/src/user/handlers/update-roles.ts +7 -23
- package/packages/bundled-features/src/user/invited-global-roles.ts +58 -0
- package/packages/bundled-features/src/user-data-rights/feature.ts +3 -0
- package/packages/bundled-features/src/waitlist/changes.json +17 -0
- package/packages/bundled-features/src/waitlist/constants.ts +44 -0
- package/packages/bundled-features/src/waitlist/entity.ts +76 -0
- package/packages/bundled-features/src/waitlist/feature.ts +50 -0
- package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +175 -0
- package/packages/bundled-features/src/waitlist/handlers/list.query.ts +12 -0
- package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +120 -0
- package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +163 -0
- package/packages/bundled-features/src/waitlist/i18n.ts +33 -0
- package/packages/bundled-features/src/waitlist/index.ts +15 -0
- package/packages/bundled-features/src/waitlist/lib.ts +36 -0
- package/packages/bundled-features/src/waitlist/mail.ts +114 -0
- package/packages/bundled-features/src/waitlist/options.ts +41 -0
- package/packages/bundled-features/src/waitlist/payloads.ts +55 -0
- package/packages/bundled-features/src/waitlist/screens.ts +43 -0
- package/packages/bundled-features/src/waitlist/user-data-hooks.ts +82 -0
- package/packages/bundled-features/src/workflow-runner/changes.json +7 -0
- package/packages/bundled-features/src/workflow-runner/db/queries/due-runs.ts +10 -13
- package/packages/bundled-features/src/workflow-runner/feature.ts +4 -11
- package/samples/apps/use-all-bundled/feature-manifest.json +36 -5
- package/samples/apps/use-all-bundled/kumiko/migrations/0033_waitlist.rebuild.json +4 -0
- package/samples/apps/use-all-bundled/schema/generate.ts +5 -0
- package/samples/apps/use-all-bundled/schema/schema.generated.ts +3 -0
- package/samples/apps/use-all-bundled/src/run-config.ts +2 -0
- package/samples/recipes/chat-channels/README.md +2 -0
- package/samples/recipes/managed-config/README.md +1 -1
- package/samples/recipes/rate-limiting/README.md +21 -0
- package/packages/bundled-features/src/billing-foundation/db/queries/payment-projection.ts +0 -19
- package/packages/bundled-features/src/billing-foundation/db/queries/subscription-projection.ts +0 -15
- 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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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(
|
|
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(
|
|
119
|
+
function renderBrandingFooter(
|
|
120
|
+
branding: MailBranding | undefined,
|
|
121
|
+
locale: string | undefined,
|
|
122
|
+
): string {
|
|
44
123
|
if (!branding) return "";
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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",
|
|
@@ -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 {
|
|
4
|
-
|
|
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
|
-
|
|
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
|
});
|
|
@@ -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;
|