@cosmicdrift/kumiko-samples 0.374.0 → 0.377.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +6 -4
  3. package/packages/bundled-features/src/account-security/changes.json +6 -0
  4. package/packages/bundled-features/src/account-security/constants.ts +1 -0
  5. package/packages/bundled-features/src/account-security/index.ts +1 -0
  6. package/packages/bundled-features/src/admin-shell/web/client-plugin.tsx +1 -1
  7. package/packages/bundled-features/src/audit/web/client-plugin.tsx +1 -1
  8. package/packages/bundled-features/src/auth-email-password/mail-notifications.ts +1 -1
  9. package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +1 -1
  10. package/packages/bundled-features/src/auth-email-password/web/auth-form-primitives.tsx +1 -1
  11. package/packages/bundled-features/src/auth-email-password/web/client-plugin.ts +3 -3
  12. package/packages/bundled-features/src/auth-email-password/web/default-topbar-actions.tsx +1 -1
  13. package/packages/bundled-features/src/auth-email-password/web/index.ts +1 -1
  14. package/packages/bundled-features/src/auth-email-password/web/session.tsx +1 -1
  15. package/packages/bundled-features/src/auth-email-password/web/tenant-menu-items.tsx +1 -1
  16. package/packages/bundled-features/src/auth-email-password/web/tenant-switcher.tsx +1 -1
  17. package/packages/bundled-features/src/auth-email-password/web/user-menu.tsx +1 -1
  18. package/packages/bundled-features/src/auth-foundation/changes.json +6 -0
  19. package/packages/bundled-features/src/auth-foundation/env.ts +27 -0
  20. package/packages/bundled-features/src/auth-foundation/index.ts +1 -0
  21. package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
  22. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +1 -1
  23. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +1 -1
  24. package/packages/bundled-features/src/billing-foundation/contact-card.ts +14 -0
  25. package/packages/bundled-features/src/billing-foundation/handlers/billing-plans.query.ts +1 -1
  26. package/packages/bundled-features/src/billing-foundation/index.ts +1 -0
  27. package/packages/bundled-features/src/billing-foundation/plan-catalog.ts +1 -0
  28. package/packages/bundled-features/src/billing-foundation/types.ts +12 -0
  29. package/packages/bundled-features/src/billing-foundation/validate-options.ts +16 -0
  30. package/packages/bundled-features/src/billing-foundation/web/billing-contact-plan.tsx +32 -0
  31. package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +5 -15
  32. package/packages/bundled-features/src/billing-foundation/web/cancel-contract-dialog.tsx +2 -2
  33. package/packages/bundled-features/src/billing-foundation/web/checkout-consent-dialog.tsx +1 -1
  34. package/packages/bundled-features/src/billing-foundation/web/client-plugin.tsx +1 -1
  35. package/packages/bundled-features/src/billing-foundation/web/localized-params.ts +14 -0
  36. package/packages/bundled-features/src/cap-overview/web/cap-cards-panel.tsx +3 -3
  37. package/packages/bundled-features/src/cap-overview/web/client-plugin.tsx +1 -1
  38. package/packages/bundled-features/src/channel-email/changes.json +6 -0
  39. package/packages/bundled-features/src/channel-email/email-channel.ts +2 -1
  40. package/packages/bundled-features/src/compliance-profiles/web/client-plugin.tsx +1 -1
  41. package/packages/bundled-features/src/config/feature.ts +2 -2
  42. package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
  43. package/packages/bundled-features/src/custom-fields/web/client-plugin.tsx +1 -1
  44. package/packages/bundled-features/src/delivery/unsubscribe-page.ts +1 -1
  45. package/packages/bundled-features/src/delivery/web/client-plugin.tsx +1 -1
  46. package/packages/bundled-features/src/delivery/web/delivery-status-cell.tsx +1 -1
  47. package/packages/bundled-features/src/feature-toggles/web/client-plugin.tsx +1 -1
  48. package/packages/bundled-features/src/file-provider-s3/changes.json +6 -0
  49. package/packages/bundled-features/src/file-provider-s3/env-named-feature.ts +45 -0
  50. package/packages/bundled-features/src/file-provider-s3/env-schema.ts +20 -13
  51. package/packages/bundled-features/src/file-provider-s3/index.ts +4 -0
  52. package/packages/bundled-features/src/folders/web/client-plugin.tsx +1 -1
  53. package/packages/bundled-features/src/folders/web/folder-section.tsx +3 -4
  54. package/packages/bundled-features/src/host-routing/README.md +7 -0
  55. package/packages/bundled-features/src/host-routing/changes.json +6 -0
  56. package/packages/bundled-features/src/host-routing/constants.ts +4 -0
  57. package/packages/bundled-features/src/host-routing/dispatch.ts +34 -26
  58. package/packages/bundled-features/src/host-routing/feature.ts +122 -41
  59. package/packages/bundled-features/src/host-routing/host.ts +28 -1
  60. package/packages/bundled-features/src/host-routing/index.ts +3 -0
  61. package/packages/bundled-features/src/host-routing/options.ts +45 -2
  62. package/packages/bundled-features/src/host-routing/redirects.ts +33 -0
  63. package/packages/bundled-features/src/host-routing/tenant-lookup.ts +65 -0
  64. package/packages/bundled-features/src/host-routing/validate-options.ts +48 -1
  65. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +7 -0
  66. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +8 -0
  67. package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +42 -0
  68. package/packages/bundled-features/src/inbound-mail-foundation/index.ts +4 -0
  69. package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +100 -0
  70. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +2 -2
  71. package/packages/bundled-features/src/jobs/web/client-plugin.tsx +1 -1
  72. package/packages/bundled-features/src/legal-pages/changes.json +6 -0
  73. package/packages/bundled-features/src/legal-pages/feature.ts +14 -1
  74. package/packages/bundled-features/src/legal-pages/layout.ts +6 -6
  75. package/packages/bundled-features/src/legal-pages/web/client-plugin.ts +1 -1
  76. package/packages/bundled-features/src/mail-foundation/feature.ts +21 -22
  77. package/packages/bundled-features/src/managed-pages/web/client-plugin.tsx +1 -1
  78. package/packages/bundled-features/src/notes-history/web/client-plugin.tsx +1 -1
  79. package/packages/bundled-features/src/page-render/branding.ts +1 -1
  80. package/packages/bundled-features/src/page-render/layout.ts +2 -2
  81. package/packages/bundled-features/src/page-render/markdown.ts +1 -1
  82. package/packages/bundled-features/src/personal-access-tokens/i18n.ts +1 -1
  83. package/packages/bundled-features/src/personal-access-tokens/screens.ts +1 -1
  84. package/packages/bundled-features/src/secrets/feature.ts +4 -4
  85. package/packages/bundled-features/src/seo/sitemap.ts +1 -1
  86. package/packages/bundled-features/src/shared/mail-template-body.ts +1 -1
  87. package/packages/bundled-features/src/shared/purge-files-reaching-subject.ts +127 -2
  88. package/packages/bundled-features/src/tags/web/client-plugin.tsx +1 -1
  89. package/packages/bundled-features/src/template-resolver/changes.json +18 -0
  90. package/packages/bundled-features/src/template-resolver/document-blocks.ts +34 -0
  91. package/packages/bundled-features/src/template-resolver/document-ops.ts +515 -0
  92. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +9 -0
  93. package/packages/bundled-features/src/template-resolver/feature.ts +4 -0
  94. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +1 -0
  95. package/packages/bundled-features/src/template-resolver/handlers/document-base.ts +86 -0
  96. package/packages/bundled-features/src/template-resolver/handlers/document-blocks.query.ts +48 -0
  97. package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +11 -3
  98. package/packages/bundled-features/src/template-resolver/handlers/patch-draft.write.ts +125 -0
  99. package/packages/bundled-features/src/template-resolver/handlers/preview.query.ts +2 -1
  100. package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +2 -1
  101. package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +22 -1
  102. package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +24 -9
  103. package/packages/bundled-features/src/template-resolver/{web/placeholder-text.ts → placeholder-text.ts} +1 -1
  104. package/packages/bundled-features/src/template-resolver/qualified-names.ts +2 -0
  105. package/packages/bundled-features/src/template-resolver/seeding.ts +3 -1
  106. package/packages/bundled-features/src/template-resolver/template-bundle.ts +6 -1
  107. package/packages/bundled-features/src/template-resolver/template-preview.ts +24 -8
  108. package/packages/bundled-features/src/template-resolver/template-validation.ts +1 -1
  109. package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +3 -3
  110. package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +1 -1
  111. package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +7 -33
  112. package/packages/bundled-features/src/template-resolver/web/i18n.ts +24 -0
  113. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +2 -2
  114. package/packages/bundled-features/src/tenant/web/client-plugin.tsx +1 -1
  115. package/packages/bundled-features/src/tenant/web/member-status-cell.tsx +1 -1
  116. package/packages/bundled-features/src/tier-engine/web/client-plugin.tsx +1 -1
  117. package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
  118. package/packages/bundled-features/src/user-data-rights/env.ts +15 -0
  119. package/packages/bundled-features/src/user-data-rights/feature.ts +4 -2
  120. package/packages/bundled-features/src/user-data-rights/index.ts +1 -0
  121. package/packages/bundled-features/src/user-data-rights/lib/mail-transport-resolver.ts +1 -1
  122. package/packages/bundled-features/src/user-data-rights/lib/storage-provider-resolver.ts +1 -1
  123. package/packages/bundled-features/src/user-data-rights/mail-notifications.ts +1 -1
  124. package/packages/bundled-features/src/user-data-rights/web/client-plugin.tsx +1 -1
  125. package/packages/bundled-features/src/user-data-rights/web/privacy-center-screen.tsx +2 -2
  126. package/packages/bundled-features/src/waitlist/changes.json +6 -0
  127. package/packages/bundled-features/src/waitlist/legacy-migrate.ts +51 -2
  128. package/samples/apps/admin-console/package.json +0 -3
  129. package/samples/apps/admin-console/src/app/client.tsx +1 -1
  130. package/samples/apps/admin-console/src/app/shell.tsx +1 -1
  131. package/samples/apps/config-demo/package.json +1 -3
  132. package/samples/apps/config-demo/src/app/client.tsx +1 -1
  133. package/samples/apps/config-demo/src/app/shell.tsx +2 -2
  134. package/samples/apps/config-demo/src/features/demo/web/index.ts +1 -1
  135. package/samples/apps/config-demo/tsconfig.json +0 -12
  136. package/samples/apps/marketing-demo/package.json +1 -3
  137. package/samples/apps/marketing-demo/src/app/client.tsx +1 -1
  138. package/samples/apps/marketing-demo/src/app/shell.tsx +1 -1
  139. package/samples/apps/marketing-demo/src/features/assets/web/index.ts +1 -1
  140. package/samples/apps/marketing-demo/src/features/helpdesk/web/index.ts +1 -1
  141. package/samples/apps/showcase/package.json +1 -3
  142. package/samples/apps/showcase/src/app/client.tsx +1 -1
  143. package/samples/apps/showcase/src/app/shell.tsx +1 -1
  144. package/samples/apps/showcase/src/features/demos/web/index.ts +1 -1
  145. package/samples/apps/showcase/src/features/demos/web/pages/sidebar-panel.tsx +1 -1
  146. package/samples/apps/showcase/src/features/demos/web/pages/toast.tsx +1 -1
  147. package/samples/apps/showcase/src/features/items/web/index.ts +1 -1
  148. package/samples/apps/styleguide/package.json +1 -3
  149. package/samples/apps/styleguide/src/app/client.tsx +1 -1
  150. package/samples/apps/styleguide/src/app/shell.tsx +2 -2
  151. package/samples/apps/styleguide/src/features/ai-settings/web.ts +1 -1
  152. package/samples/apps/styleguide/src/features/content/web.tsx +2 -2
  153. package/samples/apps/styleguide/src/features/demo/web.ts +1 -1
  154. package/samples/apps/styleguide/src/features/examples/web.ts +1 -1
  155. package/samples/apps/styleguide/src/features/gallery/web/Gallery.tsx +1 -1
  156. package/samples/apps/styleguide/src/features/gallery/web/index.ts +1 -1
  157. package/samples/apps/styleguide/src/features/widgets/demo-locale.ts +1 -1
  158. package/samples/apps/styleguide/src/features/widgets/lib/format.ts +1 -1
  159. package/samples/apps/styleguide/src/features/widgets/web/DashboardFilterEcho.tsx +1 -1
  160. package/samples/apps/styleguide/src/features/widgets/web/FormExamples.tsx +1 -1
  161. package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +1 -1
  162. package/samples/apps/styleguide/src/features/widgets/web/index.ts +1 -1
  163. package/samples/apps/styleguide/tsconfig.json +0 -12
  164. package/samples/apps/ui-walkthrough/package.json +1 -3
  165. package/samples/apps/ui-walkthrough/src/app/client.tsx +1 -1
  166. package/samples/apps/ui-walkthrough/src/app/shell.tsx +1 -1
  167. package/samples/apps/ui-walkthrough/src/features/billing/web/index.ts +1 -1
  168. package/samples/apps/ui-walkthrough/src/features/rental/web/index.ts +1 -1
  169. package/samples/apps/ui-walkthrough/src/features/tasks/web/index.ts +1 -1
  170. package/samples/apps/ui-walkthrough/src/features/vehicles/web/index.ts +1 -1
  171. package/samples/apps/use-all-bundled/bin/main.ts +3 -2
  172. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +12 -0
  173. package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +1 -1
  174. package/samples/apps/use-all-bundled/feature-manifest.json +1 -0
  175. package/samples/apps/use-all-bundled/package.json +1 -3
  176. package/samples/apps/use-all-bundled/src/app/client.tsx +1 -1
  177. package/samples/apps/use-all-bundled/src/app/dev-app-options.ts +5 -4
  178. package/samples/apps/use-all-bundled/src/app/shell.tsx +1 -1
  179. package/samples/apps/use-all-bundled/src/run-config.ts +10 -1
  180. package/samples/apps/workspaces/package.json +1 -3
  181. package/samples/apps/workspaces/src/app/client.tsx +1 -1
  182. package/samples/apps/workspaces/src/app/shell.tsx +1 -1
  183. package/samples/apps/workspaces/src/features/demo/web/index.ts +1 -1
  184. package/samples/apps/workspaces/src/features/demo-driver/web/index.ts +1 -1
  185. package/samples/recipes/apex-landing/package.json +1 -1
  186. package/samples/recipes/apex-landing/src/feature.ts +1 -1
  187. package/samples/recipes/apex-surface-auth/README.md +1 -1
  188. package/samples/recipes/apex-surface-auth/src/feature.ts +2 -2
  189. package/samples/recipes/compose-stacks/README.md +7 -4
  190. package/samples/recipes/embedded-entity-form/package.json +0 -1
  191. package/samples/recipes/embedded-entity-form/src/web/accept-suggestion-drawer.tsx +1 -1
  192. package/samples/recipes/notes-history-basic/README.md +1 -1
  193. package/samples/recipes/record-detail-layout/package.json +0 -1
  194. package/samples/recipes/tags-basic/README.md +1 -1
  195. package/samples/recipes/user-profile/README.md +1 -1
  196. package/samples/recipes/wizard-form/package.json +0 -1
  197. package/samples/recipes/wizard-form/src/web/index.ts +2 -2
  198. package/samples/recipes/wizard-form/src/web/listing-review-section.tsx +2 -2
  199. package/samples/recipes/writeform-section/package.json +0 -1
@@ -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-web";
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-web";
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-web";
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-web";
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
- /** Builds the env block `createS3ProviderFromEnv(prefix)` reads, e.g.
21
- * `createS3EnvSchema({ prefix: "RELEASES_S3_" })` for a second bucket.
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
- } as S3EnvShape<TPrefix>;
55
- return z.object(shape);
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-web";
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-row: das Ordner-Input wächst, der Anlegen-Button sitzt
173
- rechts daneben. ponytail: separate Zeile, weil die Combobox keine
174
- create-on-type-Affordance hat — fold-in, wenn renderer-web ein
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 = options.tenantBy === "path" ? isPathTenantKeyCandidate : isTenantKeyCandidate;
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.tenantBy === "path") {
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
- isTenantServingPublicContent,
20
- type TenantLifecycleStatus,
21
- tenantTable,
22
- } from "../tenant/index.ts";
23
- import { HOST_ROUTING_FEATURE, TENANT_KEY_MAX_LENGTH } from "./constants.ts";
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 TenantRow = {
29
- readonly id: TenantId;
30
- readonly isEnabled: boolean;
31
- readonly status: TenantLifecycleStatus;
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 isHostHeaderReader(c: unknown): c is HostHeaderReader {
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
- const apexTenantId = options.apexAnonymousTenantId ?? null;
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 (!isHostHeaderReader(c)) return null;
97
- const host = classifyHost(c.req.header("Host") ?? "", options);
98
- if (host.kind === "subdomain") return servingTenantIdByKey(db, host.key);
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
- return host.kind === "apex" ? apexTenantId : null;
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 (apexTenantId === null) return undefined;
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 (!isHostHeaderReader(c)) return null;
112
- return classifyHost(c.req.header("Host") ?? "", options).kind === "apex"
113
- ? apexTenantId
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 (hostname === baseDomain || hostname === `www.${baseDomain}`) return { kind: "apex" };
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";