@cosmicdrift/kumiko-bundled-features 0.340.0 → 0.341.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth-email-password/bootstrap.d.ts +74 -0
- package/dist/auth-email-password/bootstrap.js +165 -0
- package/dist/auth-email-password/constants.d.ts +2 -0
- package/dist/auth-email-password/constants.js +5 -0
- package/dist/auth-email-password/errors.d.ts +1 -0
- package/dist/auth-email-password/errors.js +6 -0
- package/dist/auth-email-password/feature.js +2 -1
- package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +9 -1
- package/dist/auth-email-password/handlers/invite-accept.write.js +8 -0
- package/dist/auth-email-password/handlers/invite-create.write.d.ts +11 -0
- package/dist/auth-email-password/handlers/invite-create.write.js +134 -87
- package/dist/auth-email-password/handlers/invite-signup-complete.write.js +5 -1
- package/dist/auth-email-password/handlers/signup-request.write.js +1 -0
- package/dist/auth-email-password/handlers/token-request-handler.js +1 -0
- package/dist/auth-email-password/index.d.ts +2 -0
- package/dist/auth-email-password/index.js +1 -0
- package/dist/auth-email-password/invite-token-store.d.ts +3 -0
- package/dist/auth-email-password/invite-token-store.js +5 -0
- package/dist/auth-email-password/magic-link-mail.js +1 -0
- package/dist/auth-mfa/config.d.ts +1 -1
- package/dist/auth-mfa/config.js +8 -3
- package/dist/auth-mfa/feature.d.ts +2 -0
- package/dist/auth-mfa/feature.js +2 -1
- package/dist/auth-mfa/mfa-status-checker.d.ts +2 -0
- package/dist/auth-mfa/mfa-status-checker.js +1 -1
- package/dist/billing-foundation/consumer-protection/issue-confirmation.js +1 -0
- package/dist/billing-foundation/consumer-protection/termination-mail.js +1 -0
- package/dist/billing-foundation/consumer-protection/termination-notify.js +1 -0
- package/dist/billing-foundation/consumer-protection/termination-pages.d.ts +8 -1
- package/dist/billing-foundation/consumer-protection/termination-pages.js +59 -32
- package/dist/billing-foundation/consumer-protection/termination-request.js +2 -0
- package/dist/billing-foundation/consumer-protection/termination-texts.d.ts +1 -0
- package/dist/billing-foundation/consumer-protection/termination-texts.js +3 -1
- package/dist/channel-discord/discord-channel.d.ts +4 -0
- package/dist/channel-discord/discord-channel.js +4 -0
- package/dist/channel-discord/feature.js +7 -2
- package/dist/channel-discord/index.d.ts +1 -1
- package/dist/channel-discord/index.js +1 -1
- package/dist/channel-email/email-channel.js +1 -0
- package/dist/channel-slack/feature.js +4 -2
- package/dist/channel-slack/index.d.ts +1 -1
- package/dist/channel-slack/index.js +1 -1
- package/dist/channel-slack/slack-channel.d.ts +4 -0
- package/dist/channel-slack/slack-channel.js +4 -0
- package/dist/channel-teams/feature.js +4 -2
- package/dist/channel-teams/index.d.ts +1 -1
- package/dist/channel-teams/index.js +1 -1
- package/dist/channel-teams/teams-channel.d.ts +4 -0
- package/dist/channel-teams/teams-channel.js +4 -0
- package/dist/channel-telegram/feature.js +4 -2
- package/dist/channel-telegram/index.d.ts +1 -1
- package/dist/channel-telegram/index.js +1 -1
- package/dist/channel-telegram/telegram-channel.d.ts +8 -0
- package/dist/channel-telegram/telegram-channel.js +28 -12
- package/dist/delivery/chat-webhook-channel.d.ts +5 -1
- package/dist/delivery/chat-webhook-channel.js +10 -5
- package/dist/delivery/chat-webhook-sender.d.ts +6 -2
- package/dist/delivery/chat-webhook-sender.js +13 -8
- package/dist/delivery/delivery-service.d.ts +4 -3
- package/dist/delivery/delivery-service.js +53 -33
- package/dist/delivery/index.d.ts +2 -2
- package/dist/delivery/index.js +2 -2
- package/dist/delivery/jobs.js +3 -1
- package/dist/delivery/types.d.ts +5 -3
- package/dist/legal-pages/feature.d.ts +3 -15
- package/dist/legal-pages/feature.js +20 -50
- package/dist/legal-pages/lib/boot-check.d.ts +10 -0
- package/dist/legal-pages/lib/boot-check.js +33 -0
- package/dist/legal-pages/lib/validate-routes.d.ts +2 -0
- package/dist/legal-pages/lib/validate-routes.js +17 -0
- package/dist/page-render/index.d.ts +2 -1
- package/dist/page-render/index.js +1 -0
- package/dist/page-render/layout.d.ts +7 -0
- package/dist/page-render/layout.js +5 -0
- package/dist/page-render/trailing-slash.d.ts +2 -0
- package/dist/page-render/trailing-slash.js +15 -0
- package/dist/renderer-simple/feature.js +1 -0
- package/dist/renderer-simple/index.d.ts +1 -1
- package/dist/renderer-simple/index.js +1 -1
- package/dist/renderer-simple/simple-renderer.d.ts +12 -5
- package/dist/renderer-simple/simple-renderer.js +66 -12
- package/dist/secrets/announce-secret-change.d.ts +8 -0
- package/dist/secrets/announce-secret-change.js +18 -0
- package/dist/secrets/feature.js +1 -1
- package/dist/secrets/handlers/delete.write.js +2 -0
- package/dist/secrets/handlers/set.write.js +6 -1
- package/dist/secrets/i18n.d.ts +4 -0
- package/dist/secrets/i18n.js +4 -0
- package/dist/secrets/secrets-context.d.ts +2 -0
- package/dist/secrets/secrets-context.js +3 -0
- package/dist/secrets/write-gate.d.ts +5 -1
- package/dist/secrets/write-gate.js +24 -1
- package/dist/tenant/constants.d.ts +1 -0
- package/dist/tenant/constants.js +1 -0
- package/dist/tenant/handlers/add-member.write.js +14 -3
- package/dist/tenant/handlers/invitations.query.d.ts +8 -0
- package/dist/tenant/handlers/invitations.query.js +15 -1
- package/dist/tenant/index.d.ts +1 -0
- package/dist/tenant/index.js +1 -0
- package/dist/tenant/invitation-table.d.ts +2 -0
- package/dist/tenant/invitation-table.js +10 -1
- package/dist/tenant-lifecycle/lifecycle-gate.d.ts +1 -0
- package/dist/tenant-lifecycle/lifecycle-gate.js +2 -0
- package/dist/tenant-lifecycle/run-tenant-destroy.js +2 -2
- package/dist/tier-engine/feature.js +83 -14
- package/dist/tier-engine/handlers/set-tenant-tier.write.d.ts +3 -3
- package/dist/tier-engine/handlers/set-tenant-tier.write.js +17 -10
- package/dist/user/invited-global-roles.d.ts +14 -0
- package/dist/user/invited-global-roles.js +35 -0
- package/dist/user-data-rights/feature.js +3 -0
- package/dist/waitlist/constants.d.ts +33 -0
- package/dist/waitlist/constants.js +35 -0
- package/dist/waitlist/entity.d.ts +433 -0
- package/dist/waitlist/entity.js +64 -0
- package/dist/waitlist/feature.d.ts +3 -0
- package/dist/waitlist/feature.js +38 -0
- package/dist/waitlist/handlers/invite.write.d.ts +9 -0
- package/dist/waitlist/handlers/invite.write.js +119 -0
- package/dist/waitlist/handlers/list.query.d.ts +1 -0
- package/dist/waitlist/handlers/list.query.js +10 -0
- package/dist/waitlist/handlers/reject.write.d.ts +7 -0
- package/dist/waitlist/handlers/reject.write.js +80 -0
- package/dist/waitlist/handlers/submit.write.d.ts +13 -0
- package/dist/waitlist/handlers/submit.write.js +112 -0
- package/dist/waitlist/i18n.d.ts +6 -0
- package/dist/waitlist/i18n.js +31 -0
- package/dist/waitlist/index.d.ts +5 -0
- package/dist/waitlist/index.js +4 -0
- package/dist/waitlist/lib.d.ts +10 -0
- package/dist/waitlist/lib.js +24 -0
- package/dist/waitlist/mail.d.ts +15 -0
- package/dist/waitlist/mail.js +73 -0
- package/dist/waitlist/options.d.ts +29 -0
- package/dist/waitlist/options.js +8 -0
- package/dist/waitlist/payloads.d.ts +14 -0
- package/dist/waitlist/payloads.js +47 -0
- package/dist/waitlist/screens.d.ts +2 -0
- package/dist/waitlist/screens.js +42 -0
- package/dist/waitlist/user-data-hooks.d.ts +3 -0
- package/dist/waitlist/user-data-hooks.js +71 -0
- package/package.json +17 -9
- package/src/auth-email-password/changes.json +18 -0
- package/src/auth-mfa/changes.json +6 -0
- package/src/billing-foundation/changes.json +6 -0
- package/src/channel-telegram/changes.json +9 -0
- package/src/delivery/changes.json +7 -0
- package/src/files/changes.json +13 -1
- package/src/renderer-simple/changes.json +6 -0
- package/src/secrets/changes.json +12 -0
- package/src/tenant/changes.json +6 -0
- package/src/waitlist/changes.json +17 -0
package/dist/delivery/types.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
|
|
2
2
|
import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
-
import type { NotifyOptions, NotifyPriority, Registry, SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import type { NotifyDeliveryStatus, NotifyJobDispatcher, NotifyOptions, NotifyPriority, NotifyResult, Registry, SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
4
|
import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
5
5
|
import { DELIVERY_CHANNEL_EXTENSION } from "./constants.js";
|
|
6
6
|
export type ChannelContext = {
|
|
@@ -15,6 +15,7 @@ export type ChannelMessage = {
|
|
|
15
15
|
readonly title: string;
|
|
16
16
|
readonly body: string | undefined;
|
|
17
17
|
readonly data: Readonly<Record<string, unknown>> | undefined;
|
|
18
|
+
readonly locale?: string | undefined;
|
|
18
19
|
};
|
|
19
20
|
export type ChannelResult = {
|
|
20
21
|
readonly status: "sent" | "failed" | "skipped";
|
|
@@ -37,6 +38,7 @@ export type DeliveryChannel = {
|
|
|
37
38
|
export type RendererInput = {
|
|
38
39
|
readonly template: string;
|
|
39
40
|
readonly variables: Readonly<Record<string, unknown>>;
|
|
41
|
+
readonly locale?: string | undefined;
|
|
40
42
|
};
|
|
41
43
|
export type NotificationRenderer = {
|
|
42
44
|
readonly name: string;
|
|
@@ -48,12 +50,12 @@ export type DeliveryLogEntry = {
|
|
|
48
50
|
readonly channel: string;
|
|
49
51
|
readonly recipientId: string | null;
|
|
50
52
|
readonly recipientAddress: string | null;
|
|
51
|
-
readonly status:
|
|
53
|
+
readonly status: NotifyDeliveryStatus;
|
|
52
54
|
readonly error: string | null;
|
|
53
55
|
readonly priority: NotifyPriority;
|
|
54
56
|
};
|
|
55
57
|
export type DeliveryService = {
|
|
56
|
-
notify(notificationType: string, options: NotifyOptions, user: SessionUser, tenantId: TenantId): Promise<
|
|
58
|
+
notify(notificationType: string, options: NotifyOptions, user: SessionUser, tenantId: TenantId, jobDispatcher?: NotifyJobDispatcher): Promise<NotifyResult>;
|
|
57
59
|
};
|
|
58
60
|
export type DeliveryChannelPlugin = Omit<DeliveryChannel, "name">;
|
|
59
61
|
export declare function isDeliveryChannelPlugin(o: unknown): o is DeliveryChannelPlugin;
|
|
@@ -1,12 +1,8 @@
|
|
|
1
|
-
import { type TemplateResolverApi } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
|
|
2
1
|
import { type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { type PublicPageWrapLayout } from "../page-render/index.js";
|
|
3
3
|
import { type LegalPageRoute, type LegalRequiredBlock } from "./constants.js";
|
|
4
|
-
export type
|
|
5
|
-
|
|
6
|
-
readonly bodyHtml: string;
|
|
7
|
-
readonly lang: string;
|
|
8
|
-
readonly slug?: string;
|
|
9
|
-
}) => string;
|
|
4
|
+
export { type LegalPagesBootCheckCtx, runLegalPagesBootCheck } from "./lib/boot-check.js";
|
|
5
|
+
export type LegalPagesWrapLayout = PublicPageWrapLayout;
|
|
10
6
|
export type LegalPagesOptions = {
|
|
11
7
|
/** Custom Layout-Wrapper für die /legal/*-Routes. Default: minimaler
|
|
12
8
|
* HTML-Skeleton aus markdown.ts (`wrapInLayout`). Apps die ihr eigenes
|
|
@@ -27,11 +23,3 @@ export type LegalPagesOptions = {
|
|
|
27
23
|
readonly requiredBlocks?: readonly LegalRequiredBlock[];
|
|
28
24
|
};
|
|
29
25
|
export declare function createLegalPagesFeature(opts?: LegalPagesOptions): FeatureDefinition;
|
|
30
|
-
export type LegalPagesBootCheckCtx = {
|
|
31
|
-
readonly templateResolver?: TemplateResolverApi;
|
|
32
|
-
readonly log?: {
|
|
33
|
-
readonly info?: (msg: string) => void;
|
|
34
|
-
readonly warn?: (msg: string) => void;
|
|
35
|
-
};
|
|
36
|
-
};
|
|
37
|
-
export declare function runLegalPagesBootCheck(ctx: LegalPagesBootCheckCtx, requiredBlocks?: readonly LegalRequiredBlock[]): Promise<void>;
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { requireTemplateResolver, TEXT_BLOCK_KIND, } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
|
|
2
1
|
import { computeRevisionEtag, etagMatches } from "@cosmicdrift/kumiko-framework/api";
|
|
3
2
|
import { defineFeature, SYSTEM_TENANT_ID, } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
-
import { cachedSecurePageResponse } from "../page-render/index.js";
|
|
3
|
+
import { cachedSecurePageResponse, redirectToCanonicalPath, slashVariantOf, } from "../page-render/index.js";
|
|
5
4
|
import { LEGAL_REQUIRED_BLOCKS, LEGAL_ROUTES, } from "./constants.js";
|
|
5
|
+
import { runLegalPagesBootCheck } from "./lib/boot-check.js";
|
|
6
|
+
import { validateRoutes } from "./lib/validate-routes.js";
|
|
6
7
|
import { renderMarkdownToHtml, wrapInLayout } from "./markdown.js";
|
|
8
|
+
export { runLegalPagesBootCheck } from "./lib/boot-check.js";
|
|
7
9
|
// QN-Konstante als dokumentierter Public-Contract des template-resolver-
|
|
8
10
|
// Features. Ein magic-string statt eines Code-Imports ist hier explizit
|
|
9
11
|
// gewollt: Cross-Feature-Calls gehen nur über stable Public-API
|
|
@@ -14,23 +16,6 @@ import { renderMarkdownToHtml, wrapInLayout } from "./markdown.js";
|
|
|
14
16
|
const TEXT_BLOCK_BY_SLUG_QN = "template-resolver:query:by-slug";
|
|
15
17
|
// 60s-shared-cache saves the origin-revalidate roundtrip; legal-content edits are live within 60s.
|
|
16
18
|
const PUBLIC_PAGE_CACHE = { kind: "revalidate", maxAgeSeconds: 60 };
|
|
17
|
-
// Validated once at feature-build time (not per-request) so a config typo
|
|
18
|
-
// fails app startup instead of surfacing as a confusing 404 later.
|
|
19
|
-
function validateRoutes(routes) {
|
|
20
|
-
const seenPaths = new Set();
|
|
21
|
-
for (const route of routes) {
|
|
22
|
-
if (!route.path || !route.slug || !route.lang) {
|
|
23
|
-
throw new Error(`legal-pages: route is missing a required field (path/slug/lang): ${JSON.stringify(route)}`);
|
|
24
|
-
}
|
|
25
|
-
if (!route.path.startsWith("/")) {
|
|
26
|
-
throw new Error(`legal-pages: route path must start with "/", got "${route.path}"`);
|
|
27
|
-
}
|
|
28
|
-
if (seenPaths.has(route.path)) {
|
|
29
|
-
throw new Error(`legal-pages: duplicate route path "${route.path}"`);
|
|
30
|
-
}
|
|
31
|
-
seenPaths.add(route.path);
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
19
|
export function createLegalPagesFeature(opts = {}) {
|
|
35
20
|
const wrapLayout = opts.wrapLayout ?? wrapInLayout;
|
|
36
21
|
const routes = opts.routes ?? LEGAL_ROUTES;
|
|
@@ -56,6 +41,8 @@ export function createLegalPagesFeature(opts = {}) {
|
|
|
56
41
|
method: "GET",
|
|
57
42
|
path: route.path,
|
|
58
43
|
anonymous: true,
|
|
44
|
+
// Public pages backed by a DB query: bound the per-IP read rate.
|
|
45
|
+
rateLimit: { per: "ip+handler", limit: 60, windowSeconds: 60 },
|
|
59
46
|
handler: async (c, { systemQuery }) => {
|
|
60
47
|
// Architektur: 1 App = X Tenants = 1 Impressum. Egal welche
|
|
61
48
|
// Subdomain der Visitor besucht (apex, admin.*, tenant-x.*) —
|
|
@@ -113,6 +100,20 @@ export function createLegalPagesFeature(opts = {}) {
|
|
|
113
100
|
},
|
|
114
101
|
});
|
|
115
102
|
}
|
|
103
|
+
// Hono matches strictly, so "/legal/x/" would 404 for sites that link with
|
|
104
|
+
// a trailing slash. GET also serves HEAD.
|
|
105
|
+
const configuredPaths = new Set(routes.map((route) => route.path));
|
|
106
|
+
for (const route of routes) {
|
|
107
|
+
const variant = slashVariantOf(route.path);
|
|
108
|
+
if (variant === null || configuredPaths.has(variant))
|
|
109
|
+
continue;
|
|
110
|
+
r.httpRoute({
|
|
111
|
+
method: "GET",
|
|
112
|
+
path: variant,
|
|
113
|
+
anonymous: true,
|
|
114
|
+
handler: (c) => redirectToCanonicalPath(c.req.url, route.path),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
116
117
|
// Boot-Check via ctx.templateResolver (extraContext-Pattern, symmetrisch
|
|
117
118
|
// zu requireConfigResolver in config). App-Bootstrap muss templateResolver
|
|
118
119
|
// wired haben — der Helper gibt einen klaren Wiring-Hinweis wenn nicht.
|
|
@@ -132,34 +133,3 @@ export function createLegalPagesFeature(opts = {}) {
|
|
|
132
133
|
return {};
|
|
133
134
|
});
|
|
134
135
|
}
|
|
135
|
-
// Exportiert für direkte Tests. Wirft InternalError wenn ctx.templateResolver
|
|
136
|
-
// nicht gewired ist (Hinweis auf fehlenden extraContext). Wirft Error
|
|
137
|
-
// in NODE_ENV=production wenn Pflicht-Blocks fehlen, sonst log.warn.
|
|
138
|
-
// Logged log.info wenn alles vorhanden ist (kein silent-skip).
|
|
139
|
-
export async function runLegalPagesBootCheck(ctx, requiredBlocks = LEGAL_REQUIRED_BLOCKS) {
|
|
140
|
-
const templateResolver = requireTemplateResolver(ctx, "legal-pages-boot-check");
|
|
141
|
-
const missing = [];
|
|
142
|
-
for (const required of requiredBlocks) {
|
|
143
|
-
const block = await templateResolver.findExact({
|
|
144
|
-
tenantId: SYSTEM_TENANT_ID,
|
|
145
|
-
slug: required.slug,
|
|
146
|
-
kind: TEXT_BLOCK_KIND,
|
|
147
|
-
locale: required.lang,
|
|
148
|
-
});
|
|
149
|
-
if (!block?.content) {
|
|
150
|
-
missing.push({ slug: required.slug, lang: required.lang });
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
if (missing.length === 0) {
|
|
154
|
-
ctx.log?.info?.("legal-pages boot-check: alle Pflicht-Blocks vorhanden");
|
|
155
|
-
}
|
|
156
|
-
else {
|
|
157
|
-
const message = `legal-pages: missing ${missing.length} required text-block(s) in SYSTEM_TENANT: ` +
|
|
158
|
-
missing.map((m) => `${m.slug}/${m.lang}`).join(", ") +
|
|
159
|
-
". Seed via template-resolver:write:set or the seedTextBlock helper.";
|
|
160
|
-
if (process.env["NODE_ENV"] === "production") {
|
|
161
|
-
throw new Error(`Boot-Validation failed: ${message}`);
|
|
162
|
-
}
|
|
163
|
-
ctx.log?.warn?.(message);
|
|
164
|
-
}
|
|
165
|
-
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type TemplateResolverApi } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
|
|
2
|
+
import { type LegalRequiredBlock } from "../constants.js";
|
|
3
|
+
export type LegalPagesBootCheckCtx = {
|
|
4
|
+
readonly templateResolver?: TemplateResolverApi;
|
|
5
|
+
readonly log?: {
|
|
6
|
+
readonly info?: (msg: string) => void;
|
|
7
|
+
readonly warn?: (msg: string) => void;
|
|
8
|
+
};
|
|
9
|
+
};
|
|
10
|
+
export declare function runLegalPagesBootCheck(ctx: LegalPagesBootCheckCtx, requiredBlocks?: readonly LegalRequiredBlock[]): Promise<void>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { requireTemplateResolver, TEXT_BLOCK_KIND, } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
|
|
2
|
+
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { LEGAL_REQUIRED_BLOCKS } from "../constants.js";
|
|
4
|
+
// Missing required blocks fail the boot only in production; elsewhere they warn,
|
|
5
|
+
// so a dev setup without seeded legal texts still starts. A complete set logs
|
|
6
|
+
// info on purpose, so a skipped check is visible.
|
|
7
|
+
export async function runLegalPagesBootCheck(ctx, requiredBlocks = LEGAL_REQUIRED_BLOCKS) {
|
|
8
|
+
const templateResolver = requireTemplateResolver(ctx, "legal-pages-boot-check");
|
|
9
|
+
const missing = [];
|
|
10
|
+
for (const required of requiredBlocks) {
|
|
11
|
+
const block = await templateResolver.findExact({
|
|
12
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
13
|
+
slug: required.slug,
|
|
14
|
+
kind: TEXT_BLOCK_KIND,
|
|
15
|
+
locale: required.lang,
|
|
16
|
+
});
|
|
17
|
+
if (!block?.content) {
|
|
18
|
+
missing.push({ slug: required.slug, lang: required.lang });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (missing.length === 0) {
|
|
22
|
+
ctx.log?.info?.("legal-pages boot-check: alle Pflicht-Blocks vorhanden");
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
const message = `legal-pages: missing ${missing.length} required text-block(s) in SYSTEM_TENANT: ` +
|
|
26
|
+
missing.map((m) => `${m.slug}/${m.lang}`).join(", ") +
|
|
27
|
+
". Seed via template-resolver:write:set or the seedTextBlock helper.";
|
|
28
|
+
if (process.env["NODE_ENV"] === "production") {
|
|
29
|
+
throw new Error(`Boot-Validation failed: ${message}`);
|
|
30
|
+
}
|
|
31
|
+
ctx.log?.warn?.(message);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Validated once at feature-build time (not per-request) so a config typo
|
|
2
|
+
// fails app startup instead of surfacing as a confusing 404 later.
|
|
3
|
+
export function validateRoutes(routes) {
|
|
4
|
+
const seenPaths = new Set();
|
|
5
|
+
for (const route of routes) {
|
|
6
|
+
if (!route.path || !route.slug || !route.lang) {
|
|
7
|
+
throw new Error(`legal-pages: route is missing a required field (path/slug/lang): ${JSON.stringify(route)}`);
|
|
8
|
+
}
|
|
9
|
+
if (!route.path.startsWith("/")) {
|
|
10
|
+
throw new Error(`legal-pages: route path must start with "/", got "${route.path}"`);
|
|
11
|
+
}
|
|
12
|
+
if (seenPaths.has(route.path)) {
|
|
13
|
+
throw new Error(`legal-pages: duplicate route path "${route.path}"`);
|
|
14
|
+
}
|
|
15
|
+
seenPaths.add(route.path);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { type BrandingTokens, brandingHeaderHtml, brandingStyleBlock, EMPTY_BRANDING, isSafeHexColor, isSafeHttpsUrl, layoutMaxWidth, } from "./branding.js";
|
|
2
2
|
export { type CachedSecurePageResponseInit, cachedSecurePageResponse, } from "./cached-page-response.js";
|
|
3
3
|
export { sanitizeTenantCss } from "./css-sanitize.js";
|
|
4
|
-
export { type SeoHeadInput, TENANT_CONTENT_ATTR, tenantStyleBlock, wrapInLayout, } from "./layout.js";
|
|
4
|
+
export { type PublicPageWrapLayout, type SeoHeadInput, TENANT_CONTENT_ATTR, tenantStyleBlock, wrapInLayout, } from "./layout.js";
|
|
5
5
|
export { renderSafeMarkdown } from "./markdown.js";
|
|
6
6
|
export { securePageHeaders } from "./security-headers.js";
|
|
7
|
+
export { redirectToCanonicalPath, slashVariantOf } from "./trailing-slash.js";
|
|
@@ -4,3 +4,4 @@ export { sanitizeTenantCss } from "./css-sanitize.js";
|
|
|
4
4
|
export { TENANT_CONTENT_ATTR, tenantStyleBlock, wrapInLayout, } from "./layout.js";
|
|
5
5
|
export { renderSafeMarkdown } from "./markdown.js";
|
|
6
6
|
export { securePageHeaders } from "./security-headers.js";
|
|
7
|
+
export { redirectToCanonicalPath, slashVariantOf } from "./trailing-slash.js";
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { type ApexHead } from "@cosmicdrift/kumiko-headless/apex";
|
|
2
2
|
import { type BrandingTokens } from "./branding.js";
|
|
3
3
|
export type SeoHeadInput = Omit<ApexHead, "title" | "description" | "lang">;
|
|
4
|
+
export type PublicPageWrapLayout = (opts: {
|
|
5
|
+
readonly title: string;
|
|
6
|
+
readonly bodyHtml: string;
|
|
7
|
+
readonly lang: string;
|
|
8
|
+
readonly slug?: string;
|
|
9
|
+
readonly alternates?: Readonly<Record<string, string>>;
|
|
10
|
+
}) => string;
|
|
4
11
|
export declare const TENANT_CONTENT_ATTR = "data-tenant-content";
|
|
5
12
|
export declare function tenantStyleBlock(customCss: string): string;
|
|
6
13
|
export declare function wrapInLayout(opts: {
|
|
@@ -81,6 +81,11 @@ ${headHtml}
|
|
|
81
81
|
a { color: var(--accent); }
|
|
82
82
|
code { background: #f4f4f4; padding: 0.1rem 0.3rem; border-radius: 3px; }
|
|
83
83
|
hr { border: 0; border-top: 1px solid #ddd; margin: 2rem 0; }
|
|
84
|
+
input, select, textarea, button { font: inherit; color: inherit; padding: 0.4rem 0.6rem;
|
|
85
|
+
border: 1px solid #bbb; border-radius: 4px; box-sizing: border-box; }
|
|
86
|
+
input:not([type="radio"]):not([type="checkbox"]):not([type="hidden"]), select, textarea {
|
|
87
|
+
width: 100%; max-width: 100%; }
|
|
88
|
+
button { cursor: pointer; background: #f4f4f4; }
|
|
84
89
|
.brand-header { position: relative; z-index: 1; display: flex; align-items: center;
|
|
85
90
|
gap: 0.6rem; margin-bottom: 1.5rem; }
|
|
86
91
|
.brand-header a { display: flex; align-items: center; gap: 0.6rem; color: inherit; text-decoration: none; }
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// The other trailing-slash form of a path, or null for the root path (it has
|
|
2
|
+
// no distinct variant).
|
|
3
|
+
export function slashVariantOf(path) {
|
|
4
|
+
if (path === "/")
|
|
5
|
+
return null;
|
|
6
|
+
return path.endsWith("/") ? path.slice(0, -1) : `${path}/`;
|
|
7
|
+
}
|
|
8
|
+
// Hono matches strictly, so the slash variant of a page would 404. A 301 keeps
|
|
9
|
+
// one canonical URL; the query string is carried over.
|
|
10
|
+
export function redirectToCanonicalPath(requestUrl, canonicalPath) {
|
|
11
|
+
return new Response(null, {
|
|
12
|
+
status: 301,
|
|
13
|
+
headers: { location: `${canonicalPath}${new URL(requestUrl).search}` },
|
|
14
|
+
});
|
|
15
|
+
}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { createRendererSimpleFeature, type RendererSimpleOptions } from "./feature.js";
|
|
2
|
-
export { createSimpleRenderer, type MailBranding, simpleRenderer } from "./simple-renderer.js";
|
|
2
|
+
export { createSimpleRenderer, type LocalizedText, type MailBranding, simpleRenderer, } from "./simple-renderer.js";
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { createRendererSimpleFeature } from "./feature.js";
|
|
2
|
-
export { createSimpleRenderer, simpleRenderer } from "./simple-renderer.js";
|
|
2
|
+
export { createSimpleRenderer, simpleRenderer, } from "./simple-renderer.js";
|
|
@@ -4,14 +4,21 @@ export type MailBranding = {
|
|
|
4
4
|
readonly productName?: string;
|
|
5
5
|
/** Hex color (#rgb, #rrggbb, #rrggbbaa); used for buttons and the header accent. */
|
|
6
6
|
readonly primaryColor?: string;
|
|
7
|
-
/** Absolute http(s) URL; anything else is dropped. */
|
|
7
|
+
/** Absolute http(s) URL; anything else is dropped. Mutually exclusive with `logoPath`. */
|
|
8
8
|
readonly logoUrl?: string;
|
|
9
|
-
|
|
10
|
-
|
|
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:
|
|
13
|
-
readonly url:
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
+
}
|
package/dist/secrets/feature.js
CHANGED
|
@@ -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) },
|
package/dist/secrets/i18n.d.ts
CHANGED
|
@@ -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
|
};
|
package/dist/secrets/i18n.js
CHANGED
|
@@ -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;
|