@cosmicdrift/kumiko-samples 0.373.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/changes.json +6 -0
- package/packages/bundled-features/src/auth-email-password/i18n.ts +1 -0
- 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-actions.ts +36 -0
- package/packages/bundled-features/src/auth-email-password/web/client-plugin.ts +7 -4
- 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/changes.json +7 -0
- package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +15 -76
- package/packages/bundled-features/src/cap-overview/i18n.ts +0 -2
- 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/feature.ts +17 -0
- package/samples/apps/styleguide/src/features/widgets/i18n.ts +8 -0
- 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,5 +1,3 @@
|
|
|
1
|
-
import type { DbConnection, TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
-
import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
|
|
3
1
|
import {
|
|
4
2
|
defineFeature,
|
|
5
3
|
defineQueryHandler,
|
|
@@ -16,40 +14,30 @@ import {
|
|
|
16
14
|
type TenantResolverProvider,
|
|
17
15
|
} from "../auth-foundation/index.ts";
|
|
18
16
|
import {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
} from "
|
|
23
|
-
import {
|
|
24
|
-
import { classifyHost } from "./host.ts";
|
|
17
|
+
HOST_HEADER_MAX_LENGTH,
|
|
18
|
+
HOST_ROUTING_FEATURE,
|
|
19
|
+
TENANT_KEY_MAX_LENGTH,
|
|
20
|
+
} from "./constants.ts";
|
|
21
|
+
import { classifyHost, normalizeHostname } from "./host.ts";
|
|
25
22
|
import type { HostRoutingOptions } from "./options.ts";
|
|
23
|
+
import {
|
|
24
|
+
isTenantServing,
|
|
25
|
+
servingTenantIdByKey,
|
|
26
|
+
servingTenantIdByResolver,
|
|
27
|
+
} from "./tenant-lookup.ts";
|
|
26
28
|
import { validateHostRoutingOptions } from "./validate-options.ts";
|
|
27
29
|
|
|
28
|
-
type
|
|
29
|
-
readonly
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
type RequestReader = {
|
|
31
|
+
readonly req: {
|
|
32
|
+
readonly header: (name: string) => string | undefined;
|
|
33
|
+
readonly url: string;
|
|
34
|
+
readonly path: string;
|
|
35
|
+
readonly raw?: Request;
|
|
36
|
+
};
|
|
32
37
|
};
|
|
33
38
|
|
|
34
|
-
// Disabled tenants and tenants in any destroy phase are not routable, the same
|
|
35
|
-
// rule public files and pages follow.
|
|
36
|
-
async function servingTenantIdByKey(
|
|
37
|
-
db: DbConnection | TenantDb,
|
|
38
|
-
key: string,
|
|
39
|
-
): Promise<TenantId | null> {
|
|
40
|
-
const row = await fetchOne<TenantRow>(db, tenantTable, { key });
|
|
41
|
-
return row !== undefined && isTenantServingPublicContent(row) ? row.id : null;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
async function isTenantServing(db: DbConnection, id: TenantId): Promise<boolean> {
|
|
45
|
-
const row = await fetchOne<TenantRow>(db, tenantTable, { id });
|
|
46
|
-
return row !== undefined && isTenantServingPublicContent(row);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
type HostHeaderReader = { readonly req: { readonly header: (name: string) => string | undefined } };
|
|
50
|
-
|
|
51
39
|
// auth-foundation types the Hono context as unknown to stay free of a hono dep.
|
|
52
|
-
function
|
|
40
|
+
function isRequestReader(c: unknown): c is RequestReader {
|
|
53
41
|
return (
|
|
54
42
|
typeof c === "object" &&
|
|
55
43
|
c !== null &&
|
|
@@ -57,10 +45,66 @@ function isHostHeaderReader(c: unknown): c is HostHeaderReader {
|
|
|
57
45
|
typeof c.req === "object" &&
|
|
58
46
|
c.req !== null &&
|
|
59
47
|
"header" in c.req &&
|
|
60
|
-
typeof c.req.header === "function"
|
|
48
|
+
typeof c.req.header === "function" &&
|
|
49
|
+
"url" in c.req &&
|
|
50
|
+
typeof c.req.url === "string" &&
|
|
51
|
+
"path" in c.req &&
|
|
52
|
+
typeof c.req.path === "string"
|
|
61
53
|
);
|
|
62
54
|
}
|
|
63
55
|
|
|
56
|
+
// Hono routes on the decoded path and ignores a trailing slash, so the
|
|
57
|
+
// allowlist must compare the same shape: "/api/%71uery" and "/api/query/"
|
|
58
|
+
// reach the query handler too.
|
|
59
|
+
function routedPathOf(c: RequestReader): string {
|
|
60
|
+
return c.req.path.length > 1 ? c.req.path.replace(/\/+$/, "") : c.req.path;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// "example.com" and "www.example.com" are one site: a host without its own
|
|
64
|
+
// entry falls back to the other variant's entry instead of keeping the anchor.
|
|
65
|
+
function allowlistFor(
|
|
66
|
+
options: HostRoutingOptions,
|
|
67
|
+
hostname: string,
|
|
68
|
+
): readonly string[] | undefined {
|
|
69
|
+
const allowlists = options.apexAnonymousQueryAllowlist;
|
|
70
|
+
if (allowlists === undefined) return undefined;
|
|
71
|
+
const counterpart = hostname.startsWith("www.") ? hostname.slice(4) : `www.${hostname}`;
|
|
72
|
+
for (const key of [hostname, counterpart]) {
|
|
73
|
+
if (Object.hasOwn(allowlists, key)) return allowlists[key];
|
|
74
|
+
}
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Both carry { type, payload } and run read handlers; /api/batch is commands only.
|
|
79
|
+
const TYPED_READ_PATHS: ReadonlySet<string> = new Set(["/api/query", "/api/stream"]);
|
|
80
|
+
const queryTypeBodySchema = z.object({ type: z.string() });
|
|
81
|
+
|
|
82
|
+
// Reads the query type from a clone, so the body stays intact for the handler.
|
|
83
|
+
async function peekQueryType(c: RequestReader): Promise<string | null> {
|
|
84
|
+
if (c.req.raw === undefined) return null;
|
|
85
|
+
try {
|
|
86
|
+
const parsed = queryTypeBodySchema.safeParse(await c.req.raw.clone().json());
|
|
87
|
+
return parsed.success ? parsed.data.type : null;
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Apex anchor tenant for anonymous calls. A host with an allowlist only gets
|
|
94
|
+
// the anchor for the listed query types on read paths; other paths keep it.
|
|
95
|
+
async function apexAnchorFor(
|
|
96
|
+
options: HostRoutingOptions,
|
|
97
|
+
c: RequestReader,
|
|
98
|
+
hostname: string,
|
|
99
|
+
): Promise<TenantId | null> {
|
|
100
|
+
const anchor = options.apexAnonymousTenantId ?? null;
|
|
101
|
+
if (anchor === null) return null;
|
|
102
|
+
const allowlist = allowlistFor(options, hostname);
|
|
103
|
+
if (allowlist === undefined || !TYPED_READ_PATHS.has(routedPathOf(c))) return anchor;
|
|
104
|
+
const type = await peekQueryType(c);
|
|
105
|
+
return type !== null && allowlist.includes(type) ? anchor : null;
|
|
106
|
+
}
|
|
107
|
+
|
|
64
108
|
// Returns only { exists }: the same fact the tenant 404 page already makes
|
|
65
109
|
// public, never other tenant fields. Rate-limited so it is no cheap oracle.
|
|
66
110
|
const tenantKeyExistsQuery = defineQueryHandler({
|
|
@@ -85,32 +129,68 @@ const tenantKeyExistsQuery = defineQueryHandler({
|
|
|
85
129
|
},
|
|
86
130
|
});
|
|
87
131
|
|
|
132
|
+
function hostTenantExistsQueryFor(options: HostRoutingOptions) {
|
|
133
|
+
return defineQueryHandler({
|
|
134
|
+
name: "host-tenant-exists",
|
|
135
|
+
schema: z.object({ host: z.string().min(1).max(HOST_HEADER_MAX_LENGTH) }),
|
|
136
|
+
access: { roles: ["anonymous"] },
|
|
137
|
+
rateLimit: { per: "ip+handler", limit: 120, windowSeconds: 60 },
|
|
138
|
+
// Page-routing plumbing, not a task an agent performs.
|
|
139
|
+
agent: { expose: false },
|
|
140
|
+
description:
|
|
141
|
+
"Anonymous host check backing the unknown-host 404 of the host-routing hostDispatch in tenantBy resolver mode. Returns only { exists }.",
|
|
142
|
+
handler: async (query, ctx) => {
|
|
143
|
+
if (!ctx.systemDb) {
|
|
144
|
+
throw new InternalError({
|
|
145
|
+
message: "host-routing:query:host-tenant-exists requires ctx.systemDb (r.systemScope())",
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
const hostname = normalizeHostname(query.payload.host);
|
|
149
|
+
if (hostname === null) return { exists: false };
|
|
150
|
+
const db = ctx.systemDb.acknowledgeCrossTenant(
|
|
151
|
+
"host existence check for the host-routing 404 — publicly observable via the 404 itself",
|
|
152
|
+
);
|
|
153
|
+
return { exists: (await servingTenantIdByResolver(options, db, hostname)) !== null };
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
88
158
|
function tenantResolverFor(options: HostRoutingOptions): TenantResolverProvider | undefined {
|
|
89
|
-
|
|
90
|
-
if (options.tenantBy === "host") {
|
|
159
|
+
if (options.tenantBy !== "path") {
|
|
91
160
|
return {
|
|
92
161
|
trust: "authoritative",
|
|
93
162
|
build:
|
|
94
163
|
({ db }) =>
|
|
95
164
|
async (c: unknown): Promise<TenantId | null> => {
|
|
96
|
-
if (!
|
|
97
|
-
const
|
|
98
|
-
|
|
165
|
+
if (!isRequestReader(c)) return null;
|
|
166
|
+
const hostHeader = c.req.header("Host") ?? "";
|
|
167
|
+
const host = classifyHost(hostHeader, options);
|
|
99
168
|
// Fixed and unknown hosts have no anonymous tenant: an authoritative
|
|
100
169
|
// null answers tenant_required, whatever X-Tenant says.
|
|
101
|
-
|
|
170
|
+
switch (host.kind) {
|
|
171
|
+
case "subdomain":
|
|
172
|
+
return servingTenantIdByKey(db, host.key);
|
|
173
|
+
case "resolved":
|
|
174
|
+
return servingTenantIdByResolver(options, db, host.hostname);
|
|
175
|
+
case "apex":
|
|
176
|
+
return apexAnchorFor(options, c, normalizeHostname(hostHeader) ?? "");
|
|
177
|
+
case "fixed":
|
|
178
|
+
case "unknown":
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
102
181
|
},
|
|
103
182
|
};
|
|
104
183
|
}
|
|
105
|
-
if (
|
|
184
|
+
if (options.apexAnonymousTenantId === undefined) return undefined;
|
|
106
185
|
// Path mode: the client names its tenant; the apex default only fills in
|
|
107
186
|
// when it does not.
|
|
108
187
|
return {
|
|
109
188
|
trust: "fallback-only",
|
|
110
189
|
build: () => async (c: unknown) => {
|
|
111
|
-
if (!
|
|
112
|
-
|
|
113
|
-
|
|
190
|
+
if (!isRequestReader(c)) return null;
|
|
191
|
+
const hostHeader = c.req.header("Host") ?? "";
|
|
192
|
+
return classifyHost(hostHeader, options).kind === "apex"
|
|
193
|
+
? apexAnchorFor(options, c, normalizeHostname(hostHeader) ?? "")
|
|
114
194
|
: null;
|
|
115
195
|
},
|
|
116
196
|
};
|
|
@@ -128,6 +208,7 @@ export function createHostRoutingFeature(options: HostRoutingOptions): FeatureDe
|
|
|
128
208
|
r.requires({ features: ["auth-foundation", "tenant"] });
|
|
129
209
|
r.systemScope();
|
|
130
210
|
r.queryHandler(tenantKeyExistsQuery);
|
|
211
|
+
if (typeof options.tenantBy === "function") r.queryHandler(hostTenantExistsQueryFor(options));
|
|
131
212
|
|
|
132
213
|
const resolver = tenantResolverFor(options);
|
|
133
214
|
if (resolver !== undefined) {
|
|
@@ -19,6 +19,8 @@ export type HostClass =
|
|
|
19
19
|
| { readonly kind: "fixed"; readonly surface: HostRoutingSurface }
|
|
20
20
|
| { readonly kind: "apex" }
|
|
21
21
|
| { readonly kind: "subdomain"; readonly key: string }
|
|
22
|
+
// tenantBy resolver mode: any host that is neither fixed nor apex.
|
|
23
|
+
| { readonly kind: "resolved"; readonly hostname: string }
|
|
22
24
|
| { readonly kind: "unknown" };
|
|
23
25
|
|
|
24
26
|
export function classifyHost(host: string, options: HostRoutingOptions): HostClass {
|
|
@@ -31,8 +33,9 @@ export function classifyHost(host: string, options: HostRoutingOptions): HostCla
|
|
|
31
33
|
? options.hosts[hostname]
|
|
32
34
|
: undefined;
|
|
33
35
|
if (fixed !== undefined) return { kind: "fixed", surface: fixed };
|
|
36
|
+
if (isApexHostname(hostname, options.baseDomain)) return { kind: "apex" };
|
|
34
37
|
const baseDomain = options.baseDomain.toLowerCase();
|
|
35
|
-
if (
|
|
38
|
+
if (typeof options.tenantBy === "function") return { kind: "resolved", hostname };
|
|
36
39
|
const suffix = `.${baseDomain}`;
|
|
37
40
|
if (!hostname.endsWith(suffix)) return { kind: "unknown" };
|
|
38
41
|
const label = hostname.slice(0, -suffix.length);
|
|
@@ -40,3 +43,27 @@ export function classifyHost(host: string, options: HostRoutingOptions): HostCla
|
|
|
40
43
|
if (!isTenantKeyCandidate(label)) return { kind: "unknown" };
|
|
41
44
|
return { kind: "subdomain", key: label };
|
|
42
45
|
}
|
|
46
|
+
|
|
47
|
+
export function splitFirstSegment(path: string): {
|
|
48
|
+
readonly segment: string;
|
|
49
|
+
readonly rest: string;
|
|
50
|
+
} {
|
|
51
|
+
const withoutLeading = path.replace(/^\/+/, "");
|
|
52
|
+
const slash = withoutLeading.indexOf("/");
|
|
53
|
+
if (slash === -1) return { segment: withoutLeading, rest: "/" };
|
|
54
|
+
return { segment: withoutLeading.slice(0, slash), rest: withoutLeading.slice(slash) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// undefined: path is outside the prefix; null: bare prefix without a key.
|
|
58
|
+
export function tenantKeyFromPath(prefix: string, path: string): string | null | undefined {
|
|
59
|
+
if (path !== prefix && !path.startsWith(`${prefix}/`)) return undefined;
|
|
60
|
+
const { segment } = splitFirstSegment(path.slice(prefix.length));
|
|
61
|
+
return segment === "" ? null : segment;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Apex hostnames are the only ones that may carry a path-named tenant or an
|
|
65
|
+
// anonymous anchor tenant.
|
|
66
|
+
export function isApexHostname(hostname: string, baseDomain: string): boolean {
|
|
67
|
+
const base = baseDomain.toLowerCase();
|
|
68
|
+
return hostname === base || hostname === `www.${base}`;
|
|
69
|
+
}
|
|
@@ -16,6 +16,9 @@ export type {
|
|
|
16
16
|
HostRoutingApexSurface,
|
|
17
17
|
HostRoutingLocales,
|
|
18
18
|
HostRoutingOptions,
|
|
19
|
+
HostRoutingRedirect,
|
|
19
20
|
HostRoutingSurface,
|
|
20
21
|
HostRoutingTenantMode,
|
|
22
|
+
HostTenantResolver,
|
|
21
23
|
} from "./options.ts";
|
|
24
|
+
export { createHostTenantIdResolver } from "./tenant-lookup.ts";
|
|
@@ -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,
|