@cosmicdrift/kumiko-samples 0.374.0 → 0.377.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 -4
- package/packages/bundled-features/src/account-security/changes.json +6 -0
- package/packages/bundled-features/src/account-security/constants.ts +1 -0
- package/packages/bundled-features/src/account-security/index.ts +1 -0
- package/packages/bundled-features/src/admin-shell/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/audit/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/auth-email-password/mail-notifications.ts +1 -1
- package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +1 -1
- package/packages/bundled-features/src/auth-email-password/web/auth-form-primitives.tsx +1 -1
- package/packages/bundled-features/src/auth-email-password/web/client-plugin.ts +3 -3
- package/packages/bundled-features/src/auth-email-password/web/default-topbar-actions.tsx +1 -1
- package/packages/bundled-features/src/auth-email-password/web/index.ts +1 -1
- package/packages/bundled-features/src/auth-email-password/web/session.tsx +1 -1
- package/packages/bundled-features/src/auth-email-password/web/tenant-menu-items.tsx +1 -1
- package/packages/bundled-features/src/auth-email-password/web/tenant-switcher.tsx +1 -1
- package/packages/bundled-features/src/auth-email-password/web/user-menu.tsx +1 -1
- package/packages/bundled-features/src/auth-foundation/changes.json +6 -0
- package/packages/bundled-features/src/auth-foundation/env.ts +27 -0
- package/packages/bundled-features/src/auth-foundation/index.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +1 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +1 -1
- package/packages/bundled-features/src/billing-foundation/contact-card.ts +14 -0
- package/packages/bundled-features/src/billing-foundation/handlers/billing-plans.query.ts +1 -1
- package/packages/bundled-features/src/billing-foundation/index.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/plan-catalog.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/types.ts +12 -0
- package/packages/bundled-features/src/billing-foundation/validate-options.ts +16 -0
- package/packages/bundled-features/src/billing-foundation/web/billing-contact-plan.tsx +32 -0
- package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +5 -15
- package/packages/bundled-features/src/billing-foundation/web/cancel-contract-dialog.tsx +2 -2
- package/packages/bundled-features/src/billing-foundation/web/checkout-consent-dialog.tsx +1 -1
- package/packages/bundled-features/src/billing-foundation/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/billing-foundation/web/localized-params.ts +14 -0
- package/packages/bundled-features/src/cap-overview/web/cap-cards-panel.tsx +3 -3
- package/packages/bundled-features/src/cap-overview/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/channel-email/changes.json +6 -0
- package/packages/bundled-features/src/channel-email/email-channel.ts +2 -1
- package/packages/bundled-features/src/compliance-profiles/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/config/feature.ts +2 -2
- package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
- package/packages/bundled-features/src/custom-fields/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/delivery/unsubscribe-page.ts +1 -1
- package/packages/bundled-features/src/delivery/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/delivery/web/delivery-status-cell.tsx +1 -1
- package/packages/bundled-features/src/feature-toggles/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/file-provider-s3/changes.json +6 -0
- package/packages/bundled-features/src/file-provider-s3/env-named-feature.ts +45 -0
- package/packages/bundled-features/src/file-provider-s3/env-schema.ts +20 -13
- package/packages/bundled-features/src/file-provider-s3/index.ts +4 -0
- package/packages/bundled-features/src/folders/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/folders/web/folder-section.tsx +3 -4
- package/packages/bundled-features/src/host-routing/README.md +7 -0
- package/packages/bundled-features/src/host-routing/changes.json +6 -0
- package/packages/bundled-features/src/host-routing/constants.ts +4 -0
- package/packages/bundled-features/src/host-routing/dispatch.ts +34 -26
- package/packages/bundled-features/src/host-routing/feature.ts +122 -41
- package/packages/bundled-features/src/host-routing/host.ts +28 -1
- package/packages/bundled-features/src/host-routing/index.ts +3 -0
- package/packages/bundled-features/src/host-routing/options.ts +45 -2
- package/packages/bundled-features/src/host-routing/redirects.ts +33 -0
- package/packages/bundled-features/src/host-routing/tenant-lookup.ts +65 -0
- package/packages/bundled-features/src/host-routing/validate-options.ts +48 -1
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +7 -0
- package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +8 -0
- package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +42 -0
- package/packages/bundled-features/src/inbound-mail-foundation/index.ts +4 -0
- package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +100 -0
- package/packages/bundled-features/src/inbound-mail-foundation/types.ts +2 -2
- package/packages/bundled-features/src/jobs/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/legal-pages/changes.json +6 -0
- package/packages/bundled-features/src/legal-pages/feature.ts +14 -1
- package/packages/bundled-features/src/legal-pages/layout.ts +6 -6
- package/packages/bundled-features/src/legal-pages/web/client-plugin.ts +1 -1
- package/packages/bundled-features/src/mail-foundation/feature.ts +21 -22
- package/packages/bundled-features/src/managed-pages/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/notes-history/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/page-render/branding.ts +1 -1
- package/packages/bundled-features/src/page-render/layout.ts +2 -2
- package/packages/bundled-features/src/page-render/markdown.ts +1 -1
- package/packages/bundled-features/src/personal-access-tokens/i18n.ts +1 -1
- package/packages/bundled-features/src/personal-access-tokens/screens.ts +1 -1
- package/packages/bundled-features/src/secrets/feature.ts +4 -4
- package/packages/bundled-features/src/seo/sitemap.ts +1 -1
- package/packages/bundled-features/src/shared/mail-template-body.ts +1 -1
- package/packages/bundled-features/src/shared/purge-files-reaching-subject.ts +127 -2
- package/packages/bundled-features/src/tags/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/template-resolver/changes.json +18 -0
- package/packages/bundled-features/src/template-resolver/document-blocks.ts +34 -0
- package/packages/bundled-features/src/template-resolver/document-ops.ts +515 -0
- package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +9 -0
- package/packages/bundled-features/src/template-resolver/feature.ts +4 -0
- package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +1 -0
- package/packages/bundled-features/src/template-resolver/handlers/document-base.ts +86 -0
- package/packages/bundled-features/src/template-resolver/handlers/document-blocks.query.ts +48 -0
- package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +11 -3
- package/packages/bundled-features/src/template-resolver/handlers/patch-draft.write.ts +125 -0
- package/packages/bundled-features/src/template-resolver/handlers/preview.query.ts +2 -1
- package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +2 -1
- package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +22 -1
- package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +24 -9
- package/packages/bundled-features/src/template-resolver/{web/placeholder-text.ts → placeholder-text.ts} +1 -1
- package/packages/bundled-features/src/template-resolver/qualified-names.ts +2 -0
- package/packages/bundled-features/src/template-resolver/seeding.ts +3 -1
- package/packages/bundled-features/src/template-resolver/template-bundle.ts +6 -1
- package/packages/bundled-features/src/template-resolver/template-preview.ts +24 -8
- package/packages/bundled-features/src/template-resolver/template-validation.ts +1 -1
- package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +3 -3
- package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +1 -1
- package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +7 -33
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +24 -0
- package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +2 -2
- package/packages/bundled-features/src/tenant/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/tenant/web/member-status-cell.tsx +1 -1
- package/packages/bundled-features/src/tier-engine/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
- package/packages/bundled-features/src/user-data-rights/env.ts +15 -0
- package/packages/bundled-features/src/user-data-rights/feature.ts +4 -2
- package/packages/bundled-features/src/user-data-rights/index.ts +1 -0
- package/packages/bundled-features/src/user-data-rights/lib/mail-transport-resolver.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/lib/storage-provider-resolver.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/mail-notifications.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/web/client-plugin.tsx +1 -1
- package/packages/bundled-features/src/user-data-rights/web/privacy-center-screen.tsx +2 -2
- package/packages/bundled-features/src/waitlist/changes.json +6 -0
- package/packages/bundled-features/src/waitlist/legacy-migrate.ts +51 -2
- package/samples/apps/admin-console/package.json +0 -3
- package/samples/apps/admin-console/src/app/client.tsx +1 -1
- package/samples/apps/admin-console/src/app/shell.tsx +1 -1
- package/samples/apps/config-demo/package.json +1 -3
- package/samples/apps/config-demo/src/app/client.tsx +1 -1
- package/samples/apps/config-demo/src/app/shell.tsx +2 -2
- package/samples/apps/config-demo/src/features/demo/web/index.ts +1 -1
- package/samples/apps/config-demo/tsconfig.json +0 -12
- package/samples/apps/marketing-demo/package.json +1 -3
- package/samples/apps/marketing-demo/src/app/client.tsx +1 -1
- package/samples/apps/marketing-demo/src/app/shell.tsx +1 -1
- package/samples/apps/marketing-demo/src/features/assets/web/index.ts +1 -1
- package/samples/apps/marketing-demo/src/features/helpdesk/web/index.ts +1 -1
- package/samples/apps/showcase/package.json +1 -3
- package/samples/apps/showcase/src/app/client.tsx +1 -1
- package/samples/apps/showcase/src/app/shell.tsx +1 -1
- package/samples/apps/showcase/src/features/demos/web/index.ts +1 -1
- package/samples/apps/showcase/src/features/demos/web/pages/sidebar-panel.tsx +1 -1
- package/samples/apps/showcase/src/features/demos/web/pages/toast.tsx +1 -1
- package/samples/apps/showcase/src/features/items/web/index.ts +1 -1
- package/samples/apps/styleguide/package.json +1 -3
- package/samples/apps/styleguide/src/app/client.tsx +1 -1
- package/samples/apps/styleguide/src/app/shell.tsx +2 -2
- package/samples/apps/styleguide/src/features/ai-settings/web.ts +1 -1
- package/samples/apps/styleguide/src/features/content/web.tsx +2 -2
- package/samples/apps/styleguide/src/features/demo/web.ts +1 -1
- package/samples/apps/styleguide/src/features/examples/web.ts +1 -1
- package/samples/apps/styleguide/src/features/gallery/web/Gallery.tsx +1 -1
- package/samples/apps/styleguide/src/features/gallery/web/index.ts +1 -1
- package/samples/apps/styleguide/src/features/widgets/demo-locale.ts +1 -1
- package/samples/apps/styleguide/src/features/widgets/lib/format.ts +1 -1
- package/samples/apps/styleguide/src/features/widgets/web/DashboardFilterEcho.tsx +1 -1
- package/samples/apps/styleguide/src/features/widgets/web/FormExamples.tsx +1 -1
- package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +1 -1
- package/samples/apps/styleguide/src/features/widgets/web/index.ts +1 -1
- package/samples/apps/styleguide/tsconfig.json +0 -12
- package/samples/apps/ui-walkthrough/package.json +1 -3
- package/samples/apps/ui-walkthrough/src/app/client.tsx +1 -1
- package/samples/apps/ui-walkthrough/src/app/shell.tsx +1 -1
- package/samples/apps/ui-walkthrough/src/features/billing/web/index.ts +1 -1
- package/samples/apps/ui-walkthrough/src/features/rental/web/index.ts +1 -1
- package/samples/apps/ui-walkthrough/src/features/tasks/web/index.ts +1 -1
- package/samples/apps/ui-walkthrough/src/features/vehicles/web/index.ts +1 -1
- package/samples/apps/use-all-bundled/bin/main.ts +3 -2
- package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +12 -0
- package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +1 -1
- package/samples/apps/use-all-bundled/feature-manifest.json +1 -0
- package/samples/apps/use-all-bundled/package.json +1 -3
- package/samples/apps/use-all-bundled/src/app/client.tsx +1 -1
- package/samples/apps/use-all-bundled/src/app/dev-app-options.ts +5 -4
- package/samples/apps/use-all-bundled/src/app/shell.tsx +1 -1
- package/samples/apps/use-all-bundled/src/run-config.ts +10 -1
- package/samples/apps/workspaces/package.json +1 -3
- package/samples/apps/workspaces/src/app/client.tsx +1 -1
- package/samples/apps/workspaces/src/app/shell.tsx +1 -1
- package/samples/apps/workspaces/src/features/demo/web/index.ts +1 -1
- package/samples/apps/workspaces/src/features/demo-driver/web/index.ts +1 -1
- package/samples/recipes/apex-landing/package.json +1 -1
- package/samples/recipes/apex-landing/src/feature.ts +1 -1
- package/samples/recipes/apex-surface-auth/README.md +1 -1
- package/samples/recipes/apex-surface-auth/src/feature.ts +2 -2
- package/samples/recipes/compose-stacks/README.md +7 -4
- package/samples/recipes/embedded-entity-form/package.json +0 -1
- package/samples/recipes/embedded-entity-form/src/web/accept-suggestion-drawer.tsx +1 -1
- package/samples/recipes/notes-history-basic/README.md +1 -1
- package/samples/recipes/record-detail-layout/package.json +0 -1
- package/samples/recipes/tags-basic/README.md +1 -1
- package/samples/recipes/user-profile/README.md +1 -1
- package/samples/recipes/wizard-form/package.json +0 -1
- package/samples/recipes/wizard-form/src/web/index.ts +2 -2
- package/samples/recipes/wizard-form/src/web/listing-review-section.tsx +2 -2
- package/samples/recipes/writeform-section/package.json +0 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { DbConnection, TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
1
2
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
3
|
|
|
3
4
|
// What a host (or a tenant) gets served on the HTML fallback.
|
|
@@ -20,11 +21,43 @@ export type HostRoutingLocales = {
|
|
|
20
21
|
readonly prefixed: readonly string[];
|
|
21
22
|
};
|
|
22
23
|
|
|
24
|
+
// Maps a normalized hostname (lowercase, no port) that is neither the apex nor
|
|
25
|
+
// a fixed host to the tenant it belongs to, e.g. a subdomain or custom domain
|
|
26
|
+
// stored in an app table. Null means "no tenant here": the dispatch answers
|
|
27
|
+
// 404 and the API answers tenant_required, never a default tenant. The
|
|
28
|
+
// result still has to be an enabled, active tenant. Runs on every request
|
|
29
|
+
// that reaches such a host, so keep it to one indexed lookup.
|
|
30
|
+
export type HostTenantResolver = (
|
|
31
|
+
host: string,
|
|
32
|
+
db: DbConnection | TenantDb,
|
|
33
|
+
) => Promise<TenantId | null> | TenantId | null;
|
|
34
|
+
|
|
23
35
|
export type HostRoutingTenantMode =
|
|
24
36
|
// <key>.<baseDomain> names the tenant.
|
|
25
|
-
| {
|
|
37
|
+
| {
|
|
38
|
+
readonly tenantBy: "host";
|
|
39
|
+
// Also serve the tenant surface on the apex under <tenantPathPrefix>/<key>/...
|
|
40
|
+
readonly tenantPathPrefix?: string;
|
|
41
|
+
}
|
|
26
42
|
// <baseDomain><tenantPathPrefix>/<key>/... names the tenant.
|
|
27
|
-
| { readonly tenantBy: "path"; readonly tenantPathPrefix: string }
|
|
43
|
+
| { readonly tenantBy: "path"; readonly tenantPathPrefix: string }
|
|
44
|
+
// The app resolves every other host itself (custom domains, profile subdomains).
|
|
45
|
+
| {
|
|
46
|
+
readonly tenantBy: HostTenantResolver;
|
|
47
|
+
readonly tenantPathPrefix?: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Fixed, app-configured redirect. `to` is a same-origin path or an https URL;
|
|
51
|
+
// nothing from the request ever picks the target, only the matched remainder
|
|
52
|
+
// of the path is appended.
|
|
53
|
+
export type HostRoutingRedirect = {
|
|
54
|
+
// Path (exact or as a segment prefix, e.g. "/old" matches "/old" and "/old/x").
|
|
55
|
+
readonly from: string;
|
|
56
|
+
readonly to: string;
|
|
57
|
+
// Limits the rule to one lowercase hostname without port. Omit for every host.
|
|
58
|
+
readonly host?: string;
|
|
59
|
+
readonly status?: 301 | 302;
|
|
60
|
+
};
|
|
28
61
|
|
|
29
62
|
export type HostRoutingOptions = HostRoutingTenantMode & {
|
|
30
63
|
// Apex host without port, e.g. "example.com". "www.<baseDomain>" counts as apex.
|
|
@@ -40,4 +73,14 @@ export type HostRoutingOptions = HostRoutingTenantMode & {
|
|
|
40
73
|
// Tenant for anonymous API calls on the apex (e.g. SYSTEM_TENANT_ID for
|
|
41
74
|
// public signup forms). Omit and the apex answers tenant_required.
|
|
42
75
|
readonly apexAnonymousTenantId?: TenantId;
|
|
76
|
+
// Per apex hostname ("example.com" or "www.example.com"): the only
|
|
77
|
+
// /api/query and /api/stream types that get the apexAnonymousTenantId anchor. Other anonymous
|
|
78
|
+
// queries on that host get no tenant (tenant_required). "example.com" and
|
|
79
|
+
// "www.example.com" share one entry: a host without its own uses the other's.
|
|
80
|
+
// Other paths (e.g. /api/batch) keep the anchor for everything.
|
|
81
|
+
// Not allowed with tenantBy "path" (boot error): there the client's X-Tenant
|
|
82
|
+
// wins over the resolver, so the allowlist would be ineffective.
|
|
83
|
+
readonly apexAnonymousQueryAllowlist?: Readonly<Record<string, readonly string[]>>;
|
|
84
|
+
// Checked first, before any host classification.
|
|
85
|
+
readonly redirects?: readonly HostRoutingRedirect[];
|
|
43
86
|
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { normalizeHostname } from "./host.ts";
|
|
2
|
+
import type { HostRoutingRedirect } from "./options.ts";
|
|
3
|
+
|
|
4
|
+
// Same-origin targets collapse leading slashes and backslashes so an appended
|
|
5
|
+
// "//evil.example" remainder can never turn into a protocol-relative URL.
|
|
6
|
+
export function sameOriginPath(path: string): string {
|
|
7
|
+
return `/${path.replace(/^[/\\]+/, "")}`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function remainderAfter(from: string, path: string): string | null {
|
|
11
|
+
if (path === from) return "";
|
|
12
|
+
const prefix = from.endsWith("/") ? from : `${from}/`;
|
|
13
|
+
return path.startsWith(prefix) ? path.slice(prefix.length - 1) : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function matchRedirect(
|
|
17
|
+
redirects: readonly HostRoutingRedirect[],
|
|
18
|
+
host: string,
|
|
19
|
+
path: string,
|
|
20
|
+
search: string,
|
|
21
|
+
): { readonly to: string; readonly status: 301 | 302 } | undefined {
|
|
22
|
+
const hostname = normalizeHostname(host);
|
|
23
|
+
for (const rule of redirects) {
|
|
24
|
+
if (rule.host !== undefined && rule.host !== hostname) continue;
|
|
25
|
+
const remainder = remainderAfter(rule.from, path);
|
|
26
|
+
if (remainder === null) continue;
|
|
27
|
+
const target = rule.to.startsWith("https://")
|
|
28
|
+
? `${rule.to.replace(/\/$/, "")}${remainder}`
|
|
29
|
+
: sameOriginPath(`${rule.to.replace(/\/$/, "")}${remainder}`);
|
|
30
|
+
return { to: `${target}${search}`, status: rule.status ?? 301 };
|
|
31
|
+
}
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { DbConnection, TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import {
|
|
5
|
+
isTenantServingPublicContent,
|
|
6
|
+
type TenantLifecycleStatus,
|
|
7
|
+
tenantTable,
|
|
8
|
+
} from "../tenant/index.ts";
|
|
9
|
+
import { classifyHost } from "./host.ts";
|
|
10
|
+
import type { HostRoutingOptions } from "./options.ts";
|
|
11
|
+
|
|
12
|
+
type TenantRow = {
|
|
13
|
+
readonly id: TenantId;
|
|
14
|
+
readonly isEnabled: boolean;
|
|
15
|
+
readonly status: TenantLifecycleStatus;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// Disabled tenants and tenants in any destroy phase are not routable, the same
|
|
19
|
+
// rule public files and pages follow.
|
|
20
|
+
export async function servingTenantIdByKey(
|
|
21
|
+
db: DbConnection | TenantDb,
|
|
22
|
+
key: string,
|
|
23
|
+
): Promise<TenantId | null> {
|
|
24
|
+
const row = await fetchOne<TenantRow>(db, tenantTable, { key });
|
|
25
|
+
return row !== undefined && isTenantServingPublicContent(row) ? row.id : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function isTenantServing(db: DbConnection | TenantDb, id: TenantId): Promise<boolean> {
|
|
29
|
+
const row = await fetchOne<TenantRow>(db, tenantTable, { id });
|
|
30
|
+
return row !== undefined && isTenantServingPublicContent(row);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function servingTenantIdByResolver(
|
|
34
|
+
options: HostRoutingOptions,
|
|
35
|
+
db: DbConnection | TenantDb,
|
|
36
|
+
hostname: string,
|
|
37
|
+
): Promise<TenantId | null> {
|
|
38
|
+
if (typeof options.tenantBy !== "function") return null;
|
|
39
|
+
const resolved = await options.tenantBy(hostname, db);
|
|
40
|
+
return resolved !== null && (await isTenantServing(db, resolved)) ? resolved : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The tenant a Host header belongs to, without any request: subdomain key,
|
|
44
|
+
// resolver result or the apex anchor. Fixed and unknown hosts have none.
|
|
45
|
+
// Meant for features that serve per-tenant content by host, e.g. the
|
|
46
|
+
// `resolveApexTenant` option of managed-pages.
|
|
47
|
+
export function createHostTenantIdResolver(
|
|
48
|
+
options: HostRoutingOptions,
|
|
49
|
+
db: DbConnection | TenantDb,
|
|
50
|
+
): (host: string) => Promise<TenantId | null> {
|
|
51
|
+
return async (host) => {
|
|
52
|
+
const classified = classifyHost(host, options);
|
|
53
|
+
switch (classified.kind) {
|
|
54
|
+
case "subdomain":
|
|
55
|
+
return options.tenantBy === "host" ? servingTenantIdByKey(db, classified.key) : null;
|
|
56
|
+
case "resolved":
|
|
57
|
+
return servingTenantIdByResolver(options, db, classified.hostname);
|
|
58
|
+
case "apex":
|
|
59
|
+
return options.apexAnonymousTenantId ?? null;
|
|
60
|
+
case "fixed":
|
|
61
|
+
case "unknown":
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -1,9 +1,51 @@
|
|
|
1
|
+
import { isApexHostname } from "./host.ts";
|
|
1
2
|
import type { HostRoutingOptions } from "./options.ts";
|
|
2
3
|
|
|
3
4
|
const DOMAIN_PATTERN = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/;
|
|
4
5
|
const PATH_PREFIX_PATTERN = /^\/[a-z0-9-]+(?:\/[a-z0-9-]+)*$/;
|
|
5
6
|
const LOCALE_PATTERN = /^[a-z]{2}(?:-[a-z]{2})?$/;
|
|
6
7
|
|
|
8
|
+
const REDIRECT_PATH_PATTERN = /^\/[A-Za-z0-9._~/-]*$/;
|
|
9
|
+
// A redirect target is either a same-origin path or an https URL; neither may
|
|
10
|
+
// carry a query or fragment, because the request's query string is appended.
|
|
11
|
+
const REDIRECT_TARGET_PATTERN =
|
|
12
|
+
/^(?:\/(?!\/)[A-Za-z0-9._~/-]*|https:\/\/[a-z0-9.-]+(?::[0-9]{1,5})?(?:\/[A-Za-z0-9._~/-]*)?)$/;
|
|
13
|
+
|
|
14
|
+
function validateRedirects(options: HostRoutingOptions): void {
|
|
15
|
+
for (const rule of options.redirects ?? []) {
|
|
16
|
+
if (!REDIRECT_PATH_PATTERN.test(rule.from)) {
|
|
17
|
+
throw new Error(`host-routing: redirect from must be a path like "/old", got "${rule.from}"`);
|
|
18
|
+
}
|
|
19
|
+
if (!REDIRECT_TARGET_PATTERN.test(rule.to)) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
`host-routing: redirect to must be a same-origin path or an https URL without query, got "${rule.to}"`,
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
if (rule.host !== undefined && !DOMAIN_PATTERN.test(rule.host)) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
`host-routing: redirect host must be a lowercase hostname, got "${rule.host}"`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function validateAnonymousQueryAllowlist(options: HostRoutingOptions): void {
|
|
33
|
+
if (options.apexAnonymousQueryAllowlist !== undefined && options.tenantBy === "path") {
|
|
34
|
+
// Path mode resolves as fallback-only: a client-sent X-Tenant or cookie wins
|
|
35
|
+
// before the resolver runs, so the allowlist could never hold.
|
|
36
|
+
throw new Error(
|
|
37
|
+
'host-routing: apexAnonymousQueryAllowlist cannot be used with tenantBy "path", because the client names its tenant there and bypasses the allowlist',
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
for (const host of Object.keys(options.apexAnonymousQueryAllowlist ?? {})) {
|
|
41
|
+
if (!isApexHostname(host, options.baseDomain)) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`host-routing: apexAnonymousQueryAllowlist key must be the apex or www.<apex> hostname, got "${host}"`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
7
49
|
// Checked once when the app composes its features, so a config typo fails
|
|
8
50
|
// the boot instead of routing every request to a 404.
|
|
9
51
|
export function validateHostRoutingOptions(options: HostRoutingOptions): void {
|
|
@@ -17,11 +59,16 @@ export function validateHostRoutingOptions(options: HostRoutingOptions): void {
|
|
|
17
59
|
throw new Error(`host-routing: hosts key must be a lowercase hostname, got "${host}"`);
|
|
18
60
|
}
|
|
19
61
|
}
|
|
20
|
-
if (
|
|
62
|
+
if (
|
|
63
|
+
options.tenantPathPrefix !== undefined &&
|
|
64
|
+
!PATH_PREFIX_PATTERN.test(options.tenantPathPrefix)
|
|
65
|
+
) {
|
|
21
66
|
throw new Error(
|
|
22
67
|
`host-routing: tenantPathPrefix must look like "/app" (leading slash, no trailing slash), got "${options.tenantPathPrefix}"`,
|
|
23
68
|
);
|
|
24
69
|
}
|
|
70
|
+
validateRedirects(options);
|
|
71
|
+
validateAnonymousQueryAllowlist(options);
|
|
25
72
|
const { locales } = options;
|
|
26
73
|
if (locales !== undefined) {
|
|
27
74
|
for (const locale of [locales.default, ...locales.prefixed]) {
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.377.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Declarative mailbox list and rename screens",
|
|
6
|
+
"detail": "The feature registers the projectionList mail-accounts (name, address, provider, status, connected; row actions rename and disconnect) and the actionForm mail-account-edit, both restricted to SystemAdmin and TenantAdmin like the handlers. The list shows several mailboxes per tenant, never a credential. Screens are dormant, so the app places the list in its own nav. MAIL_ACCOUNT_LIST_SCREEN_ID and MAIL_ACCOUNT_EDIT_SCREEN_ID are exported.",
|
|
7
|
+
"migration": "Place mail-accounts in the app nav with r.nav; no other change."
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.370.0",
|
|
4
11
|
"type": "breaking",
|
|
@@ -79,6 +79,7 @@ import { ingestMessageHandler } from "./handlers/ingest-message.write.ts";
|
|
|
79
79
|
import { listAccountsQuery } from "./handlers/list-accounts.query.ts";
|
|
80
80
|
import { listMessagesQuery } from "./handlers/list-messages.query.ts";
|
|
81
81
|
import { updateAccountHandler } from "./handlers/update-account.write.ts";
|
|
82
|
+
import { INBOUND_MAIL_FOUNDATION_I18N } from "./i18n.ts";
|
|
82
83
|
import {
|
|
83
84
|
applyInboundMessageReceived,
|
|
84
85
|
applyMailAccountConnected,
|
|
@@ -90,6 +91,7 @@ import {
|
|
|
90
91
|
mailThreadsProjectionTable,
|
|
91
92
|
} from "./projection.ts";
|
|
92
93
|
import { runInboundMailRetention } from "./retention-sweep.ts";
|
|
94
|
+
import { mailAccountEditScreen, mailAccountListScreen } from "./screens.ts";
|
|
93
95
|
import {
|
|
94
96
|
INBOUND_MAIL_TENANT_DESTROY_ARCHIVE_REASON,
|
|
95
97
|
inboundMessageTenantDestroyHook,
|
|
@@ -195,6 +197,12 @@ export function createInboundMailFoundationFeature() {
|
|
|
195
197
|
r.queryHandler(listAccountsQuery);
|
|
196
198
|
r.queryHandler(listMessagesQuery);
|
|
197
199
|
|
|
200
|
+
// Declarative mailbox admin: list (rename, disconnect) and rename form.
|
|
201
|
+
// The app places `mailAccountListScreen` via r.nav in its settings area.
|
|
202
|
+
r.screen(mailAccountListScreen);
|
|
203
|
+
r.screen(mailAccountEditScreen);
|
|
204
|
+
r.translations({ keys: INBOUND_MAIL_FOUNDATION_I18N });
|
|
205
|
+
|
|
198
206
|
// Entity-genaue GDPR-Destroy-Hooks — der gdpr-storage-Boot-Validator
|
|
199
207
|
// prueft die EXT_TENANT_DATA-Registrierung pro tenant-subject-Entity.
|
|
200
208
|
r.useExtension(EXT_TENANT_DATA, "mail-account", {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { ACTION_FORM_ENTITY } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
|
+
import { fieldLabelKey } from "@cosmicdrift/kumiko-renderer/headless";
|
|
3
|
+
import { INBOUND_MAIL_FOUNDATION_FEATURE, InboundMailAccountStatuses } from "./constants.ts";
|
|
4
|
+
import {
|
|
5
|
+
MAIL_ACCOUNT_EDIT_SCREEN_ID,
|
|
6
|
+
MAIL_ACCOUNT_LIST_SCREEN_ID,
|
|
7
|
+
MAIL_ACCOUNT_STATUS_OPTION_KEY_PREFIX,
|
|
8
|
+
} from "./screens.ts";
|
|
9
|
+
|
|
10
|
+
const formFieldKey = (field: string): string =>
|
|
11
|
+
fieldLabelKey(INBOUND_MAIL_FOUNDATION_FEATURE, ACTION_FORM_ENTITY, field);
|
|
12
|
+
|
|
13
|
+
export const INBOUND_MAIL_FOUNDATION_I18N: Readonly<Record<string, { readonly en: string }>> = {
|
|
14
|
+
[`screen:${MAIL_ACCOUNT_LIST_SCREEN_ID}.title`]: { en: "Mailboxes" },
|
|
15
|
+
[`screen:${MAIL_ACCOUNT_EDIT_SCREEN_ID}.title`]: { en: "Edit mailbox" },
|
|
16
|
+
"inboundMail.accounts.col.name": { en: "Name" },
|
|
17
|
+
"inboundMail.accounts.col.address": { en: "Address" },
|
|
18
|
+
"inboundMail.accounts.col.provider": { en: "Provider" },
|
|
19
|
+
"inboundMail.accounts.col.status": { en: "Status" },
|
|
20
|
+
"inboundMail.accounts.col.connected": { en: "Connected" },
|
|
21
|
+
[`${MAIL_ACCOUNT_STATUS_OPTION_KEY_PREFIX}${InboundMailAccountStatuses.active}`]: {
|
|
22
|
+
en: "Active",
|
|
23
|
+
},
|
|
24
|
+
[`${MAIL_ACCOUNT_STATUS_OPTION_KEY_PREFIX}${InboundMailAccountStatuses.authError}`]: {
|
|
25
|
+
en: "Sign-in failed",
|
|
26
|
+
},
|
|
27
|
+
[`${MAIL_ACCOUNT_STATUS_OPTION_KEY_PREFIX}${InboundMailAccountStatuses.degraded}`]: {
|
|
28
|
+
en: "Interrupted",
|
|
29
|
+
},
|
|
30
|
+
[`${MAIL_ACCOUNT_STATUS_OPTION_KEY_PREFIX}${InboundMailAccountStatuses.disconnected}`]: {
|
|
31
|
+
en: "Disconnected",
|
|
32
|
+
},
|
|
33
|
+
"inboundMail.accounts.edit": { en: "Rename" },
|
|
34
|
+
"inboundMail.accounts.edit.submit": { en: "Save" },
|
|
35
|
+
"inboundMail.accounts.disconnect": { en: "Disconnect" },
|
|
36
|
+
"inboundMail.accounts.disconnect.confirm": {
|
|
37
|
+
en: "Disconnect this mailbox? No new mail is fetched; stored mail is kept.",
|
|
38
|
+
},
|
|
39
|
+
[formFieldKey("accountId")]: { en: "Mailbox" },
|
|
40
|
+
[formFieldKey("displayName")]: { en: "Name" },
|
|
41
|
+
[formFieldKey("reason")]: { en: "Reason" },
|
|
42
|
+
};
|
|
@@ -77,6 +77,10 @@ export {
|
|
|
77
77
|
resolveInboundProviderForAccount,
|
|
78
78
|
resolveInboundProviderForKey,
|
|
79
79
|
} from "./provider-factory.ts";
|
|
80
|
+
export {
|
|
81
|
+
MAIL_ACCOUNT_EDIT_SCREEN_ID,
|
|
82
|
+
MAIL_ACCOUNT_LIST_SCREEN_ID,
|
|
83
|
+
} from "./screens.ts";
|
|
80
84
|
export {
|
|
81
85
|
InboundAuthError,
|
|
82
86
|
InboundCursorInvalidError,
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import {
|
|
2
|
+
i18nKey,
|
|
3
|
+
MAX_LIST_LIMIT,
|
|
4
|
+
type ProjectionListScreenDefinition,
|
|
5
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
+
import type { ActionFormScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
7
|
+
import {
|
|
8
|
+
InboundMailAccountStatuses,
|
|
9
|
+
InboundMailFoundationHandlers,
|
|
10
|
+
InboundMailFoundationQueries,
|
|
11
|
+
} from "./constants.ts";
|
|
12
|
+
|
|
13
|
+
export const MAIL_ACCOUNT_LIST_SCREEN_ID = "mail-accounts";
|
|
14
|
+
export const MAIL_ACCOUNT_EDIT_SCREEN_ID = "mail-account-edit";
|
|
15
|
+
|
|
16
|
+
export const MAIL_ACCOUNT_STATUS_OPTION_KEY_PREFIX = "inboundMail.accounts.status.";
|
|
17
|
+
|
|
18
|
+
// Same roles as the update/disconnect handlers: a screen rule wider than the
|
|
19
|
+
// handlers would only render actions that 403.
|
|
20
|
+
const MAIL_ACCOUNT_ADMIN_ACCESS = { roles: ["SystemAdmin", "TenantAdmin"] } as const;
|
|
21
|
+
|
|
22
|
+
// Several mailboxes per tenant, one row each. The bound query already filters
|
|
23
|
+
// personal mailboxes the caller neither owns nor administers and returns no
|
|
24
|
+
// credential: the secret lives in the secrets feature, never in the row.
|
|
25
|
+
// dormant: the consuming app places the list in its own settings nav.
|
|
26
|
+
export const mailAccountListScreen: ProjectionListScreenDefinition = {
|
|
27
|
+
id: MAIL_ACCOUNT_LIST_SCREEN_ID,
|
|
28
|
+
type: "projectionList",
|
|
29
|
+
agent: { expose: false },
|
|
30
|
+
query: InboundMailFoundationQueries.listAccounts,
|
|
31
|
+
pagination: false,
|
|
32
|
+
pageSize: MAX_LIST_LIMIT,
|
|
33
|
+
columns: [
|
|
34
|
+
{ field: "displayName", label: i18nKey("inboundMail.accounts.col.name"), sortable: false },
|
|
35
|
+
{ field: "address", label: i18nKey("inboundMail.accounts.col.address"), sortable: false },
|
|
36
|
+
{
|
|
37
|
+
field: "provider",
|
|
38
|
+
label: i18nKey("inboundMail.accounts.col.provider"),
|
|
39
|
+
sortable: false,
|
|
40
|
+
hideOnNarrow: true,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
field: "status",
|
|
44
|
+
label: i18nKey("inboundMail.accounts.col.status"),
|
|
45
|
+
sortable: false,
|
|
46
|
+
renderer: { format: "enumOption", keyPrefix: MAIL_ACCOUNT_STATUS_OPTION_KEY_PREFIX },
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
field: "connectedAt",
|
|
50
|
+
label: i18nKey("inboundMail.accounts.col.connected"),
|
|
51
|
+
sortable: false,
|
|
52
|
+
hideOnNarrow: true,
|
|
53
|
+
renderer: { format: "timestamp" },
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
rowActions: [
|
|
57
|
+
{
|
|
58
|
+
kind: "navigate",
|
|
59
|
+
id: "edit",
|
|
60
|
+
label: i18nKey("inboundMail.accounts.edit"),
|
|
61
|
+
screen: MAIL_ACCOUNT_EDIT_SCREEN_ID,
|
|
62
|
+
params: { map: { accountId: "id", displayName: "displayName" } },
|
|
63
|
+
visible: { field: "status", ne: InboundMailAccountStatuses.disconnected },
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
kind: "writeHandler",
|
|
67
|
+
id: "disconnect",
|
|
68
|
+
label: i18nKey("inboundMail.accounts.disconnect"),
|
|
69
|
+
handler: InboundMailFoundationHandlers.disconnectAccount,
|
|
70
|
+
payload: { map: { accountId: "id" } },
|
|
71
|
+
style: "danger",
|
|
72
|
+
confirm: i18nKey("inboundMail.accounts.disconnect.confirm"),
|
|
73
|
+
visible: { field: "status", ne: InboundMailAccountStatuses.disconnected },
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
access: MAIL_ACCOUNT_ADMIN_ACCESS,
|
|
77
|
+
dormant: true,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// The reason is the audit label update-account requires; it is not in the
|
|
81
|
+
// layout, so the default is what gets written.
|
|
82
|
+
export const mailAccountEditScreen: ActionFormScreenDefinition = {
|
|
83
|
+
id: MAIL_ACCOUNT_EDIT_SCREEN_ID,
|
|
84
|
+
type: "actionForm",
|
|
85
|
+
agent: { expose: false },
|
|
86
|
+
handler: InboundMailFoundationHandlers.updateAccount,
|
|
87
|
+
fields: {
|
|
88
|
+
accountId: { type: "text", required: true },
|
|
89
|
+
displayName: { type: "text", required: true, maxLength: 200 },
|
|
90
|
+
reason: { type: "text", default: "tenant_admin" },
|
|
91
|
+
},
|
|
92
|
+
layout: {
|
|
93
|
+
sections: [{ fields: [{ field: "accountId", readOnly: true }, "displayName"] }],
|
|
94
|
+
},
|
|
95
|
+
submitLabel: i18nKey("inboundMail.accounts.edit.submit"),
|
|
96
|
+
redirect: MAIL_ACCOUNT_LIST_SCREEN_ID,
|
|
97
|
+
cancelTarget: MAIL_ACCOUNT_LIST_SCREEN_ID,
|
|
98
|
+
access: MAIL_ACCOUNT_ADMIN_ACCESS,
|
|
99
|
+
dormant: true,
|
|
100
|
+
};
|
|
@@ -21,7 +21,7 @@ import { INBOUND_MAIL_PROVIDER_EXTENSION, type InboundMailAccountStatus } from "
|
|
|
21
21
|
* config — tenant config reads (host/port/... of the plugins)
|
|
22
22
|
* registry — extension lookup in the factory (not plugin-internal)
|
|
23
23
|
* secrets — tenant secret reads (IMAP password, OAuth refresh token)
|
|
24
|
-
*
|
|
24
|
+
* auditUserId — audit identity for secret reads. Handler path: the dispatcher
|
|
25
25
|
* sets the caller user ID; worker path: system identity.
|
|
26
26
|
* metricsFor — metrics handle of a feature by name, same behavior as
|
|
27
27
|
* HandlerContext.metricsFor: unregistered metrics are a silent
|
|
@@ -31,7 +31,7 @@ export type InboundMailContext = {
|
|
|
31
31
|
readonly config?: ConfigAccessor;
|
|
32
32
|
readonly registry?: Registry;
|
|
33
33
|
readonly secrets?: import("@cosmicdrift/kumiko-framework/secrets").SecretsContext;
|
|
34
|
-
readonly
|
|
34
|
+
readonly auditUserId?: string | undefined;
|
|
35
35
|
readonly metricsFor?: (featureName: string) => MetricsHandle;
|
|
36
36
|
};
|
|
37
37
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @runtime client
|
|
2
2
|
import { mergeTranslations, type TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
-
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer
|
|
3
|
+
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer/web";
|
|
4
4
|
import { JOBS_FEATURE } from "../constants.ts";
|
|
5
5
|
import { defaultTranslations } from "./i18n.ts";
|
|
6
6
|
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.376.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Legal layout receives alternates",
|
|
6
|
+
"detail": "createLegalPagesLayout passes alternates as second argument to headerHtml and footerHtml, and legal-pages routes provide the other-language paths of the same slug, so a layout can render a language switch."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.370.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
type FeatureDefinition,
|
|
5
5
|
SYSTEM_TENANT_ID,
|
|
6
6
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
-
import { escapeHtml } from "@cosmicdrift/kumiko-headless";
|
|
7
|
+
import { escapeHtml } from "@cosmicdrift/kumiko-renderer/headless";
|
|
8
8
|
import {
|
|
9
9
|
cachedSecurePageResponse,
|
|
10
10
|
type PublicPageWrapLayout,
|
|
@@ -90,6 +90,17 @@ export type LegalPagesOptions = {
|
|
|
90
90
|
readonly terms?: boolean;
|
|
91
91
|
};
|
|
92
92
|
|
|
93
|
+
// Only slugs served in more than one language get alternates.
|
|
94
|
+
function alternatesBySlugOf(
|
|
95
|
+
routes: readonly LegalPageRoute[],
|
|
96
|
+
): ReadonlyMap<string, Readonly<Record<string, string>>> {
|
|
97
|
+
const bySlug = new Map<string, Record<string, string>>();
|
|
98
|
+
for (const route of routes) {
|
|
99
|
+
bySlug.set(route.slug, { ...bySlug.get(route.slug), [route.lang]: route.path });
|
|
100
|
+
}
|
|
101
|
+
return new Map([...bySlug].filter(([, byLang]) => Object.keys(byLang).length > 1));
|
|
102
|
+
}
|
|
103
|
+
|
|
93
104
|
export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDefinition {
|
|
94
105
|
const wrapLayout = opts.wrapLayout ?? wrapInLayout;
|
|
95
106
|
const routes: readonly LegalPageRoute[] = [
|
|
@@ -108,6 +119,7 @@ export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDe
|
|
|
108
119
|
);
|
|
109
120
|
}
|
|
110
121
|
}
|
|
122
|
+
const alternatesBySlug = alternatesBySlugOf(routes);
|
|
111
123
|
return defineFeature("legal-pages", (r) => {
|
|
112
124
|
r.describe(
|
|
113
125
|
"Opt-in wrapper around `template-resolver` text-blocks that registers public HTML routes (default: `/legal/impressum`, `/legal/datenschutz`, `/legal/imprint`, `/legal/privacy`) with Markdown-to-HTML rendering and a boot-time job that hard-fails in production when the required blocks (default: `imprint/de`, `privacy/de`) are not seeded in `SYSTEM_TENANT`. Both are configurable via `routes`/`requiredBlocks` for apps with a different default language or additional pages. Requires `anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID }` and `extraContext.templateResolver` to be wired at app bootstrap; for per-tenant imprints or a custom layout call `template-resolver:query:by-slug` directly.",
|
|
@@ -189,6 +201,7 @@ export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDe
|
|
|
189
201
|
: renderedBody,
|
|
190
202
|
lang: route.lang,
|
|
191
203
|
slug: route.slug,
|
|
204
|
+
alternates: alternatesBySlug.get(route.slug),
|
|
192
205
|
});
|
|
193
206
|
|
|
194
207
|
return cachedSecurePageResponse(c.req.raw, {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
|
|
1
|
+
import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-renderer/headless";
|
|
2
2
|
import { type PublicPageWrapLayout, TENANT_CONTENT_ATTR } from "../page-render/index.ts";
|
|
3
3
|
|
|
4
4
|
export type LegalPagesLayoutOptions = {
|
|
@@ -8,8 +8,8 @@ export type LegalPagesLayoutOptions = {
|
|
|
8
8
|
readonly css?: string;
|
|
9
9
|
readonly stylesheetHrefs?: readonly string[];
|
|
10
10
|
// Trusted app chrome per language; the feature passes the route's `lang`.
|
|
11
|
-
readonly headerHtml?: (lang: string) => string;
|
|
12
|
-
readonly footerHtml?: (lang: string) => string;
|
|
11
|
+
readonly headerHtml?: (lang: string, alternates?: Readonly<Record<string, string>>) => string;
|
|
12
|
+
readonly footerHtml?: (lang: string, alternates?: Readonly<Record<string, string>>) => string;
|
|
13
13
|
readonly contentClass?: string;
|
|
14
14
|
};
|
|
15
15
|
|
|
@@ -22,9 +22,9 @@ export function createLegalPagesLayout(options: LegalPagesLayoutOptions): Public
|
|
|
22
22
|
const contentClass = options.contentClass
|
|
23
23
|
? ` class="${escapeHtmlAttr(options.contentClass)}"`
|
|
24
24
|
: "";
|
|
25
|
-
return ({ title, bodyHtml, lang }) => {
|
|
26
|
-
const headerHtml = options.headerHtml?.(lang) ?? "";
|
|
27
|
-
const footerHtml = options.footerHtml?.(lang) ?? "";
|
|
25
|
+
return ({ title, bodyHtml, lang, alternates }) => {
|
|
26
|
+
const headerHtml = options.headerHtml?.(lang, alternates) ?? "";
|
|
27
|
+
const footerHtml = options.footerHtml?.(lang, alternates) ?? "";
|
|
28
28
|
return `<!doctype html>
|
|
29
29
|
<html lang="${escapeHtmlAttr(lang)}">
|
|
30
30
|
<head>
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// fehlende Pflicht-Blocks.
|
|
18
18
|
|
|
19
19
|
import type { TreeChildrenSubscribe, TreeNode } from "@cosmicdrift/kumiko-framework/engine";
|
|
20
|
-
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer
|
|
20
|
+
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer/web";
|
|
21
21
|
import { LEGAL_OPTIONAL_BLOCKS, LEGAL_REQUIRED_BLOCKS } from "../constants.ts";
|
|
22
22
|
|
|
23
23
|
const treeProvider: TreeChildrenSubscribe = () => (emit) => {
|
|
@@ -58,34 +58,33 @@ export const MAIL_TRANSPORT_EXTENSION = "mailTransport" as const;
|
|
|
58
58
|
// =============================================================================
|
|
59
59
|
|
|
60
60
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* read-Felder die fuer Tenant-Config + Secret-Lookup noetig sind.
|
|
61
|
+
* Narrow surface type for transport plugins, mirroring file-foundation's
|
|
62
|
+
* FileProviderContext. HandlerContext carries tx, actor, signal and more;
|
|
63
|
+
* plugins only get the read fields needed for tenant config and secret lookup.
|
|
65
64
|
*
|
|
66
|
-
* **
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* ConfigAccessor
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
65
|
+
* **Why not the full HandlerContext?** On the worker path (transport builds
|
|
66
|
+
* triggered by r.job, such as the user-data-rights forget/export cron) there
|
|
67
|
+
* is no per-request `config`/`tx`/`actor`; the wrapper builds the per-tenant
|
|
68
|
+
* ConfigAccessor from `ctx.configResolver`. A plugin reading `ctx.tx` or other
|
|
69
|
+
* request-only fields would break the worker path at runtime, and that would
|
|
70
|
+
* only show up with real SMTP in production. Casting
|
|
71
|
+
* `ctx as unknown as HandlerContext` satisfies the compiler but fails at
|
|
72
|
+
* runtime in the worker. A plugin that needs more extends MailTransportContext
|
|
73
|
+
* explicitly (a visible breaking change) instead of casting ctx.
|
|
75
74
|
*
|
|
76
|
-
* **
|
|
77
|
-
* config
|
|
78
|
-
* registry
|
|
79
|
-
* secrets
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
75
|
+
* **Fields:**
|
|
76
|
+
* config — tenant config reads (the plugin's host/port/from/...)
|
|
77
|
+
* registry — extension lookup in the factory (not plugin-internal)
|
|
78
|
+
* secrets — tenant secret reads (smtp.password)
|
|
79
|
+
* auditUserId — audit identity for secret reads. Handler path: the
|
|
80
|
+
* dispatcher sets the caller's user id; worker path: the
|
|
81
|
+
* r.job wrap sets a system identity.
|
|
83
82
|
*/
|
|
84
83
|
export type MailTransportContext = {
|
|
85
84
|
readonly config?: ConfigAccessor;
|
|
86
85
|
readonly registry?: Registry;
|
|
87
86
|
readonly secrets?: import("@cosmicdrift/kumiko-framework/secrets").SecretsContext;
|
|
88
|
-
readonly
|
|
87
|
+
readonly auditUserId?: string | undefined;
|
|
89
88
|
};
|
|
90
89
|
|
|
91
90
|
/**
|
|
@@ -283,7 +282,7 @@ export function tenantMailTransport(
|
|
|
283
282
|
config: scope.config,
|
|
284
283
|
registry: scope.registry,
|
|
285
284
|
secrets: scope.secrets,
|
|
286
|
-
|
|
285
|
+
auditUserId: createSystemUser(scope.tenantId).id,
|
|
287
286
|
},
|
|
288
287
|
scope.tenantId,
|
|
289
288
|
"channel-email",
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// [managedPagesClient()] }), damit die Admin-Labels übersetzt rendern.
|
|
7
7
|
|
|
8
8
|
import { mergeTranslations, type TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
9
|
-
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer
|
|
9
|
+
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer/web";
|
|
10
10
|
import { defaultTranslations } from "./i18n.ts";
|
|
11
11
|
|
|
12
12
|
export type ManagedPagesClientOptions = {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @runtime client
|
|
2
2
|
|
|
3
|
-
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer
|
|
3
|
+
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer/web";
|
|
4
4
|
import { NOTES_HISTORY_FEATURE_NAME, NOTES_SECTION_EXTENSION_NAME } from "../constants.ts";
|
|
5
5
|
import { defaultTranslations } from "./i18n.ts";
|
|
6
6
|
import { NotesSection } from "./notes-section.tsx";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
|
|
1
|
+
import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-renderer/headless";
|
|
2
2
|
|
|
3
3
|
// Tenant-branding tokens resolved at render time (managed-pages reads them
|
|
4
4
|
// from config — see managed-pages/branding.ts). Empty string = "unset, use
|