@cosmicdrift/kumiko-samples 0.374.0 → 0.378.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-counter/changes.json +12 -0
- package/packages/bundled-features/src/cap-counter/stock-cap-guard.ts +9 -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/config/web/i18n.ts +1 -0
- 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 +13 -0
- package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +8 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-accounts.query.ts +11 -4
- 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/changes.json +6 -0
- package/packages/bundled-features/src/mail-foundation/feature.ts +21 -22
- package/packages/bundled-features/src/mail-foundation/i18n.ts +3 -2
- package/packages/bundled-features/src/mail-transport-smtp/i18n.ts +1 -1
- 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 +38 -0
- package/packages/bundled-features/src/template-resolver/compile-document-body.ts +61 -3
- package/packages/bundled-features/src/template-resolver/constants.ts +3 -0
- package/packages/bundled-features/src/template-resolver/document-blocks.ts +36 -0
- package/packages/bundled-features/src/template-resolver/document-images.ts +29 -0
- package/packages/bundled-features/src/template-resolver/document-ops.ts +515 -0
- package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +44 -0
- package/packages/bundled-features/src/template-resolver/document-schema.ts +5 -0
- package/packages/bundled-features/src/template-resolver/feature.ts +79 -34
- package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +1 -0
- package/packages/bundled-features/src/template-resolver/handlers/collection-item.query.ts +1 -1
- package/packages/bundled-features/src/template-resolver/handlers/collection-list.query.ts +1 -1
- package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +2 -6
- package/packages/bundled-features/src/template-resolver/handlers/collection-shared.ts +0 -6
- package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +67 -58
- package/packages/bundled-features/src/template-resolver/handlers/discard-draft.write.ts +36 -13
- 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/import-bundle.write.ts +37 -28
- package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +66 -62
- package/packages/bundled-features/src/template-resolver/handlers/patch-draft.write.ts +134 -0
- package/packages/bundled-features/src/template-resolver/handlers/preview.query.ts +2 -1
- package/packages/bundled-features/src/template-resolver/handlers/reset-to-system.write.ts +62 -49
- package/packages/bundled-features/src/template-resolver/handlers/resource-link-pdf.write.ts +44 -29
- package/packages/bundled-features/src/template-resolver/handlers/resource-remove.write.ts +52 -36
- package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +126 -106
- package/packages/bundled-features/src/template-resolver/handlers/send-test-shared.ts +38 -0
- package/packages/bundled-features/src/template-resolver/handlers/send-test-status.query.ts +31 -0
- package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +105 -110
- package/packages/bundled-features/src/template-resolver/handlers/set.write.ts +110 -103
- package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +22 -1
- package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +56 -28
- package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +38 -29
- package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +24 -9
- package/packages/bundled-features/src/template-resolver/index.ts +8 -1
- 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 +4 -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-edit-policy.ts +91 -0
- package/packages/bundled-features/src/template-resolver/template-preview.ts +32 -10
- package/packages/bundled-features/src/template-resolver/template-validation.ts +32 -11
- package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +3 -3
- package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +29 -0
- package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +43 -5
- package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +9 -33
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +30 -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/help-action.tsx +37 -0
- package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +2 -0
- package/samples/apps/ui-walkthrough/src/app/shell.tsx +7 -1
- package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +20 -0
- package/samples/apps/ui-walkthrough/src/features/billing/web/index.ts +1 -1
- package/samples/apps/ui-walkthrough/src/features/rental/feature.ts +65 -1
- package/samples/apps/ui-walkthrough/src/features/rental/i18n.ts +70 -0
- package/samples/apps/ui-walkthrough/src/features/rental/invoice-receive.ts +20 -0
- package/samples/apps/ui-walkthrough/src/features/rental/lease-support.ts +15 -0
- package/samples/apps/ui-walkthrough/src/features/rental/qualified-names.ts +2 -0
- package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +79 -0
- package/samples/apps/ui-walkthrough/src/features/rental/web/index.ts +3 -1
- package/samples/apps/ui-walkthrough/src/features/rental/web/upload-standard-layout.tsx +61 -0
- package/samples/apps/ui-walkthrough/src/features/tasks/i18n.ts +8 -0
- 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/ui-walkthrough/src/run-config.ts +32 -3
- 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 +3 -2
- 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/delivery-notifications/README.md +1 -1
- package/samples/recipes/embedded/README.md +8 -0
- package/samples/recipes/embedded/src/feature.ts +8 -0
- package/samples/recipes/embedded/src/handlers/invoice-receive.write.ts +38 -0
- package/samples/recipes/embedded/src/screens/invoice-receive.screen.ts +59 -0
- 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,4 +1,5 @@
|
|
|
1
1
|
import { PII_ERASED_SENTINEL } from "@cosmicdrift/kumiko-framework/crypto";
|
|
2
|
+
import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
3
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
4
|
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
4
5
|
import * as z from "zod";
|
|
@@ -185,7 +186,7 @@ type EmailChannelTransports =
|
|
|
185
186
|
export type EmailChannelOptions = EmailChannelTransports & {
|
|
186
187
|
readonly resolveEmail: (
|
|
187
188
|
userId: string,
|
|
188
|
-
ctx: { db:
|
|
189
|
+
ctx: { db: TenantDb; tenantId: TenantId },
|
|
189
190
|
) => Promise<string | null>;
|
|
190
191
|
};
|
|
191
192
|
|
|
@@ -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 {
|
|
5
5
|
COMPLIANCE_PROFILE_CATALOG_EXTENSION_NAME,
|
|
6
6
|
COMPLIANCE_PROFILES_FEATURE,
|
|
@@ -95,7 +95,7 @@ export function createConfigAccessor(
|
|
|
95
95
|
return configAccessor;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
// Pass to the test-stack / server-boot as `
|
|
98
|
+
// Pass to the test-stack / server-boot as `configAccessorFactory` —
|
|
99
99
|
// `buildHandlerContext` mints a per-user `ctx.config` from this.
|
|
100
100
|
export function createConfigAccessorFactory(
|
|
101
101
|
registry: Registry,
|
|
@@ -115,7 +115,7 @@ export function requireConfigResolver(ctx: HandlerContext, handlerName: string):
|
|
|
115
115
|
throw new InternalError({
|
|
116
116
|
message:
|
|
117
117
|
`[${handlerName}] ctx.configResolver missing — pass createConfigAccessorFactory's ` +
|
|
118
|
-
`output via extraContext.
|
|
118
|
+
`output via extraContext.configAccessorFactory and the resolver via ` +
|
|
119
119
|
`extraContext.configResolver at boot.`,
|
|
120
120
|
});
|
|
121
121
|
}
|
|
@@ -34,6 +34,7 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
34
34
|
"config.settings.extensionSelectorHint":
|
|
35
35
|
"Choose a provider. Its settings appear below and are saved together with it.",
|
|
36
36
|
"config.settings.provider": "Provider",
|
|
37
|
+
"config.settings.provider.platformDefault": "Platform default",
|
|
37
38
|
"config.settings.audience.system":
|
|
38
39
|
"Applies to every tenant unless a tenant sets its own value.",
|
|
39
40
|
"config.settings.audience.tenant":
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.377.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Forgetting a user also removes their unbound pending uploads",
|
|
6
|
+
"detail": "forget-subject for a user deleted only files bound to a record. Uploads the user made but never attached (file refs without an entity id) stayed behind. They are now removed with their bytes, except refs a record still names in a single-file column."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.372.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// } },
|
|
12
12
|
// ]}
|
|
13
13
|
|
|
14
|
-
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer
|
|
14
|
+
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer/web";
|
|
15
15
|
import { CUSTOM_FIELDS_FEATURE_NAME, CUSTOM_FIELDS_FORM_EXTENSION_NAME } from "../constants.ts";
|
|
16
16
|
import { CustomFieldsFormSection } from "./custom-fields-form-section.tsx";
|
|
17
17
|
import { defaultTranslations } from "./i18n.ts";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { mailT } from "@cosmicdrift/kumiko-framework/i18n";
|
|
3
|
-
import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
|
|
3
|
+
import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-renderer/headless";
|
|
4
4
|
import type { MailChrome, MailChromeLink } from "../renderer-foundation/index.ts";
|
|
5
5
|
import { DELIVERY_RESUBSCRIBE_PATH, DELIVERY_UNSUBSCRIBE_PATH } from "./public-names.ts";
|
|
6
6
|
import "./unsubscribe-page-strings.ts";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @runtime client
|
|
2
2
|
import 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 {
|
|
5
5
|
DELIVERY_CHANNEL_CELL_COMPONENT,
|
|
6
6
|
DELIVERY_ERROR_CELL_COMPONENT,
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// projectionList renderer — only this one cell stays TSX.
|
|
6
6
|
|
|
7
7
|
import { type ColumnRendererProps, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
8
|
-
import { StatusBadge, type StatusTone } from "@cosmicdrift/kumiko-renderer
|
|
8
|
+
import { StatusBadge, type StatusTone } from "@cosmicdrift/kumiko-renderer/web";
|
|
9
9
|
import type { ReactNode } from "react";
|
|
10
10
|
import { translateOrRaw } from "../../shared/web/translate-or-raw.ts";
|
|
11
11
|
import { DeliveryStatus } from "../public-names.ts";
|
|
@@ -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 { FEATURE_TOGGLES_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": "Named S3 provider feature",
|
|
6
|
+
"detail": "createFileProviderS3NamedEnvFeature mounts an additional S3 provider under its own name with an env prefix, next to the default one, so a second bucket needs no hand-written wiring."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.371.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
FileProviderContext,
|
|
3
|
+
FileProviderPlugin,
|
|
4
|
+
} from "@cosmicdrift/kumiko-bundled-features/file-foundation";
|
|
5
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
+
import { featureWithEnvSchema } from "@cosmicdrift/kumiko-framework/env";
|
|
7
|
+
import type { FileStorageProvider } from "@cosmicdrift/kumiko-framework/files";
|
|
8
|
+
import { createRuntimeS3EnvSchema } from "./env-schema.ts";
|
|
9
|
+
import { createS3ProviderFromEnv } from "./lib/env-helper.ts";
|
|
10
|
+
|
|
11
|
+
export type NamedS3EnvProviderOptions = {
|
|
12
|
+
/** Provider key tenants select in `file-foundation`'s `provider` config; the feature is `file-provider-s3-env-<name>`. */
|
|
13
|
+
readonly name: string;
|
|
14
|
+
/** Env prefix of the credential set, e.g. `RELEASES_S3_`. */
|
|
15
|
+
readonly envPrefix: string;
|
|
16
|
+
/** Tolerate missing credentials at boot; the provider then fails on its first file operation. */
|
|
17
|
+
readonly lazy?: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** An additional S3 provider next to `"s3-env"`, reading its credentials from `<envPrefix>BUCKET` etc. */
|
|
21
|
+
export function createFileProviderS3NamedEnvFeature(options: NamedS3EnvProviderOptions) {
|
|
22
|
+
const envSchema = createRuntimeS3EnvSchema(options.envPrefix);
|
|
23
|
+
const feature = defineFeature(
|
|
24
|
+
`file-provider-s3-env-${options.name}`,
|
|
25
|
+
(r) => {
|
|
26
|
+
r.describe(
|
|
27
|
+
`Registers the "${options.name}" provider for \`file-foundation\`: one shared S3 bucket for every tenant, credentials from the \`${options.envPrefix}*\` env vars. A second bucket next to the \`"s3-env"\` provider (for example release artifacts).`,
|
|
28
|
+
);
|
|
29
|
+
r.uiHints({
|
|
30
|
+
displayLabel: `File Provider · S3 (${options.name})`,
|
|
31
|
+
category: "storage",
|
|
32
|
+
recommended: false,
|
|
33
|
+
});
|
|
34
|
+
r.requires({ features: ["file-foundation"] });
|
|
35
|
+
r.envSchema(envSchema);
|
|
36
|
+
const plugin: FileProviderPlugin = {
|
|
37
|
+
build: async (_ctx: FileProviderContext, _tenantId: string): Promise<FileStorageProvider> =>
|
|
38
|
+
createS3ProviderFromEnv(options.envPrefix), // @wrapper-known semantic-alias
|
|
39
|
+
};
|
|
40
|
+
r.useExtension("fileProvider", options.name, plugin);
|
|
41
|
+
},
|
|
42
|
+
{ dedupeOptions: { name: options.name, envPrefix: options.envPrefix } },
|
|
43
|
+
);
|
|
44
|
+
return featureWithEnvSchema(feature, envSchema);
|
|
45
|
+
}
|
|
@@ -17,17 +17,8 @@ export const s3EndpointSchema = z
|
|
|
17
17
|
.url({ protocol: /^https?$/ })
|
|
18
18
|
.refine(isAcceptableEndpoint, "must use https unless the host is loopback");
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
* Every field is optional: an app that mounts the env provider next to
|
|
23
|
-
* another one picks the provider at runtime, and the provider reports
|
|
24
|
-
* missing credentials on its first file operation. */
|
|
25
|
-
export function createS3EnvSchema<const TPrefix extends string>(options: {
|
|
26
|
-
readonly prefix: TPrefix;
|
|
27
|
-
}) {
|
|
28
|
-
const { prefix } = options;
|
|
29
|
-
// @cast-boundary schema-walk — computed template keys widen to an index signature; the literal keys below are exactly the ones S3EnvShape names.
|
|
30
|
-
const shape = {
|
|
20
|
+
function buildS3Shape(prefix: string) {
|
|
21
|
+
return {
|
|
31
22
|
[`${prefix}BUCKET`]: z.string().min(1).optional().describe("S3 bucket."),
|
|
32
23
|
[`${prefix}REGION`]: z.string().min(1).optional().describe("S3 region."),
|
|
33
24
|
[`${prefix}ACCESS_KEY`]: z
|
|
@@ -51,8 +42,24 @@ export function createS3EnvSchema<const TPrefix extends string>(options: {
|
|
|
51
42
|
.enum(["true", "false"])
|
|
52
43
|
.optional()
|
|
53
44
|
.describe('Path-style bucket addressing ("true"), needed by MinIO and some S3 clones.'),
|
|
54
|
-
}
|
|
55
|
-
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Builds the env block `createS3ProviderFromEnv(prefix)` reads, e.g.
|
|
49
|
+
* `createS3EnvSchema({ prefix: "RELEASES_S3_" })` for a second bucket.
|
|
50
|
+
* Every field is optional: an app that mounts the env provider next to
|
|
51
|
+
* another one picks the provider at runtime, and the provider reports
|
|
52
|
+
* missing credentials on its first file operation. */
|
|
53
|
+
export function createS3EnvSchema<const TPrefix extends string>(options: {
|
|
54
|
+
readonly prefix: TPrefix;
|
|
55
|
+
}) {
|
|
56
|
+
// @cast-boundary schema-walk — computed template keys widen to an index signature; the literal keys below are exactly the ones S3EnvShape names.
|
|
57
|
+
return z.object(buildS3Shape(options.prefix) as S3EnvShape<TPrefix>);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Same block for a prefix only known at runtime (named providers); keys are typed as a record. */
|
|
61
|
+
export function createRuntimeS3EnvSchema(prefix: string) {
|
|
62
|
+
return z.object(buildS3Shape(prefix));
|
|
56
63
|
}
|
|
57
64
|
|
|
58
65
|
type OptionalString = z.ZodOptional<z.ZodString>;
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
// Public API of the file-provider-s3 bundled-feature.
|
|
2
2
|
|
|
3
|
+
export {
|
|
4
|
+
createFileProviderS3NamedEnvFeature,
|
|
5
|
+
type NamedS3EnvProviderOptions,
|
|
6
|
+
} from "./env-named-feature.ts";
|
|
3
7
|
export { createS3EnvSchema, s3EndpointSchema, s3EnvSchema } from "./env-schema.ts";
|
|
4
8
|
export {
|
|
5
9
|
createFileProviderS3Feature,
|
|
@@ -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 { FOLDER_SECTION_EXTENSION_NAME, FOLDERS_FEATURE_NAME } from "../constants.ts";
|
|
5
5
|
import { FolderSection } from "./folder-section.tsx";
|
|
6
6
|
import { defaultTranslations } from "./i18n.ts";
|
|
@@ -169,10 +169,9 @@ export function FolderSection({
|
|
|
169
169
|
/>
|
|
170
170
|
</Field>
|
|
171
171
|
|
|
172
|
-
{/* Inline create
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
onCreate-Prop bekommt. */}
|
|
172
|
+
{/* Inline create row: the folder input grows, the create button sits next
|
|
173
|
+
to it. Separate row because the combobox has no create-on-type
|
|
174
|
+
affordance; fold in once renderer/web gets an onCreate prop. */}
|
|
176
175
|
<div className="flex items-end gap-2">
|
|
177
176
|
<div className="flex-1">
|
|
178
177
|
<Field id="folder-section-new" label={t("folders.section.newLabel")}>
|
|
@@ -87,3 +87,10 @@ default applies. Options are validated when the feature or dispatch is built.
|
|
|
87
87
|
|
|
88
88
|
Custom domains per tenant, legacy host redirects and app-specific query
|
|
89
89
|
allowlists stay in the app: wrap the dispatch function and fall through to it.
|
|
90
|
+
|
|
91
|
+
## Resolver, redirects and allowlist
|
|
92
|
+
|
|
93
|
+
- `tenantBy: (host, db) => TenantId | null` resolves every host that is neither the apex nor a fixed host (profile subdomains, custom domains). The tenant must be enabled and active; null means 404 for pages and `tenant_required` for the API. `createHostTenantIdResolver(options, db)` exposes the same mapping for managed-pages' `resolveApexTenant`.
|
|
94
|
+
- `tenantPathPrefix` is optional in host and resolver mode and serves `<prefix>/<key>` on the apex. The API half still takes the apex anchor there; an app that needs a path-named tenant on API calls resolves it itself.
|
|
95
|
+
- `redirects: [{ from, to, host?, status? }]` run before host classification. `to` is configured, never derived from the request.
|
|
96
|
+
- `apexAnonymousQueryAllowlist: { "example.com": ["feature:query:x"] }` limits the anonymous apex anchor to the listed query types on that host, for `/api/query` and `/api/stream`; other paths keep the anchor. `example.com` and `www.example.com` share one entry (a host without its own uses the other's). Combined with `tenantBy: "path"` it throws at boot, because the client names its tenant there (X-Tenant wins over the resolver) and would bypass the allowlist.
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.376.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "tenantBy resolver, apex path tenants, redirects and anonymous query allowlist",
|
|
6
|
+
"detail": "tenantBy accepts (host, db) => TenantId | null for profile subdomains and custom domains; tenantPathPrefix also works in host and resolver mode for apex path tenants. redirects send fixed 301/302 answers before host classification, apexAnonymousQueryAllowlist limits the anonymous apex anchor to listed query types per host (/api/query and /api/stream), and createHostTenantIdResolver(options, db) maps a Host header to its tenant for managed-pages. Existing \"host\" and \"path\" configs behave as before, except that apexAnonymousQueryAllowlist with tenantBy \"path\" is rejected at boot."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.370.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -2,6 +2,7 @@ export const HOST_ROUTING_FEATURE = "host-routing";
|
|
|
2
2
|
|
|
3
3
|
export const HostRoutingQueries = {
|
|
4
4
|
tenantKeyExists: `${HOST_ROUTING_FEATURE}:query:tenant-key-exists`,
|
|
5
|
+
hostTenantExists: `${HOST_ROUTING_FEATURE}:query:host-tenant-exists`,
|
|
5
6
|
} as const;
|
|
6
7
|
|
|
7
8
|
// Matches tenant:write:create's key limit; keeps lookups for absurd keys out
|
|
@@ -22,3 +23,6 @@ const PATH_TENANT_KEY_PATTERN = /^[A-Za-z0-9._~-]+$/;
|
|
|
22
23
|
export function isPathTenantKeyCandidate(value: string): boolean {
|
|
23
24
|
return value.length <= TENANT_KEY_MAX_LENGTH && PATH_TENANT_KEY_PATTERN.test(value);
|
|
24
25
|
}
|
|
26
|
+
|
|
27
|
+
// Longest Host header value a hostname plus port can take.
|
|
28
|
+
export const HOST_HEADER_MAX_LENGTH = 260;
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { RateLimitError, ValidationError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
|
-
import type { PageHeadSystemQuery } from "@cosmicdrift/kumiko-headless/apex";
|
|
3
|
+
import type { PageHeadSystemQuery } from "@cosmicdrift/kumiko-renderer/headless/apex";
|
|
4
4
|
import * as z from "zod";
|
|
5
5
|
import { HostRoutingQueries, isPathTenantKeyCandidate, isTenantKeyCandidate } from "./constants.ts";
|
|
6
|
-
import { classifyHost } from "./host.ts";
|
|
6
|
+
import { classifyHost, splitFirstSegment, tenantKeyFromPath } from "./host.ts";
|
|
7
7
|
import type { HostRoutingOptions, HostRoutingSurface } from "./options.ts";
|
|
8
|
+
import { matchRedirect, sameOriginPath } from "./redirects.ts";
|
|
8
9
|
import { validateHostRoutingOptions } from "./validate-options.ts";
|
|
9
10
|
|
|
10
11
|
// Structurally identical to server-runtime's HostDispatchResult. Defined here
|
|
@@ -43,6 +44,21 @@ async function isTenantKeyKnown(systemQuery: PageHeadSystemQuery, key: string):
|
|
|
43
44
|
return tenantKeyExistsResultSchema.parse(result).exists;
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
async function isHostKnown(systemQuery: PageHeadSystemQuery, host: string): Promise<boolean> {
|
|
48
|
+
let result: unknown;
|
|
49
|
+
try {
|
|
50
|
+
result = await systemQuery(HostRoutingQueries.hostTenantExists, { host }, SYSTEM_TENANT_ID);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (error instanceof ValidationError) return false;
|
|
53
|
+
// The shell is harmless: the API resolver is authoritative and answers
|
|
54
|
+
// tenant_required for unknown hosts, while a 404 would hit visitors behind
|
|
55
|
+
// a shared NAT under load.
|
|
56
|
+
if (error instanceof RateLimitError) return true;
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
return tenantKeyExistsResultSchema.parse(result).exists;
|
|
60
|
+
}
|
|
61
|
+
|
|
46
62
|
function serve(
|
|
47
63
|
surface: HostRoutingSurface,
|
|
48
64
|
file: string = surface.file,
|
|
@@ -59,20 +75,6 @@ function notFound(surface: HostRoutingSurface | undefined): HostRoutingDispatchR
|
|
|
59
75
|
: { kind: "not-found", file: surface.file, csp: surface.csp };
|
|
60
76
|
}
|
|
61
77
|
|
|
62
|
-
// Redirect targets are always same-origin paths. Collapsing leading slashes
|
|
63
|
-
// and backslashes keeps "/de//evil.example" from turning into the
|
|
64
|
-
// protocol-relative "//evil.example".
|
|
65
|
-
function sameOriginPath(path: string): string {
|
|
66
|
-
return `/${path.replace(/^[/\\]+/, "")}`;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function splitFirstSegment(path: string): { readonly segment: string; readonly rest: string } {
|
|
70
|
-
const withoutLeading = path.replace(/^\/+/, "");
|
|
71
|
-
const slash = withoutLeading.indexOf("/");
|
|
72
|
-
if (slash === -1) return { segment: withoutLeading, rest: "/" };
|
|
73
|
-
return { segment: withoutLeading.slice(0, slash), rest: withoutLeading.slice(slash) };
|
|
74
|
-
}
|
|
75
|
-
|
|
76
78
|
function apexPage(options: HostRoutingOptions, req: HostRoutingRequest): HostRoutingDispatchResult {
|
|
77
79
|
const { apex, locales } = options;
|
|
78
80
|
if (locales === undefined) return serve(apex);
|
|
@@ -88,27 +90,25 @@ async function tenantPage(
|
|
|
88
90
|
options: HostRoutingOptions,
|
|
89
91
|
key: string | null,
|
|
90
92
|
systemQuery: PageHeadSystemQuery,
|
|
93
|
+
keySource: "path" | "subdomain",
|
|
91
94
|
): Promise<HostRoutingDispatchResult> {
|
|
92
95
|
// No key (bare tenant prefix): the shell handles login and tenant choice.
|
|
93
96
|
if (key === null) return serve(options.tenant);
|
|
94
|
-
const isCandidate =
|
|
97
|
+
const isCandidate = keySource === "path" ? isPathTenantKeyCandidate : isTenantKeyCandidate;
|
|
95
98
|
if (!isCandidate(key)) return notFound(options.unknownTenant);
|
|
96
99
|
return (await isTenantKeyKnown(systemQuery, key))
|
|
97
100
|
? serve(options.tenant)
|
|
98
101
|
: notFound(options.unknownTenant);
|
|
99
102
|
}
|
|
100
103
|
|
|
101
|
-
function tenantKeyFromPath(prefix: string, path: string): string | null | undefined {
|
|
102
|
-
if (path !== prefix && !path.startsWith(`${prefix}/`)) return undefined;
|
|
103
|
-
const { segment } = splitFirstSegment(path.slice(prefix.length));
|
|
104
|
-
return segment === "" ? null : segment;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
104
|
// The routing decision for runProdApp's `hostDispatch` (and, via
|
|
108
105
|
// toDevHostDispatch, the dev server). Unknown hosts fail closed with 404.
|
|
109
106
|
export function createHostDispatch(options: HostRoutingOptions): HostRoutingDispatchFn {
|
|
110
107
|
validateHostRoutingOptions(options);
|
|
108
|
+
const redirects = options.redirects ?? [];
|
|
111
109
|
return async (req, { systemQuery }) => {
|
|
110
|
+
const redirect = matchRedirect(redirects, req.host, req.path, req.search);
|
|
111
|
+
if (redirect !== undefined) return { kind: "redirect", ...redirect };
|
|
112
112
|
const host = classifyHost(req.host, options);
|
|
113
113
|
switch (host.kind) {
|
|
114
114
|
case "fixed":
|
|
@@ -117,11 +117,19 @@ export function createHostDispatch(options: HostRoutingOptions): HostRoutingDisp
|
|
|
117
117
|
return notFound(undefined);
|
|
118
118
|
case "subdomain":
|
|
119
119
|
if (options.tenantBy !== "host") return notFound(undefined);
|
|
120
|
-
return tenantPage(options, host.key, systemQuery);
|
|
120
|
+
return tenantPage(options, host.key, systemQuery, "subdomain");
|
|
121
|
+
case "resolved": {
|
|
122
|
+
if (await isHostKnown(systemQuery, host.hostname)) return serve(options.tenant);
|
|
123
|
+
return notFound(
|
|
124
|
+
host.hostname.endsWith(`.${options.baseDomain.toLowerCase()}`)
|
|
125
|
+
? options.unknownTenant
|
|
126
|
+
: undefined,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
121
129
|
case "apex": {
|
|
122
|
-
if (options.
|
|
130
|
+
if (options.tenantPathPrefix !== undefined) {
|
|
123
131
|
const key = tenantKeyFromPath(options.tenantPathPrefix, req.path);
|
|
124
|
-
if (key !== undefined) return tenantPage(options, key, systemQuery);
|
|
132
|
+
if (key !== undefined) return tenantPage(options, key, systemQuery, "path");
|
|
125
133
|
}
|
|
126
134
|
return apexPage(options, req);
|
|
127
135
|
}
|
|
@@ -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) {
|