@cosmicdrift/kumiko-bundled-features 0.373.0 → 0.377.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (215) hide show
  1. package/dist/account-security/constants.d.ts +1 -0
  2. package/dist/account-security/constants.js +1 -0
  3. package/dist/account-security/index.d.ts +1 -1
  4. package/dist/account-security/index.js +1 -1
  5. package/dist/admin-shell/web/client-plugin.d.ts +1 -1
  6. package/dist/audit/web/client-plugin.d.ts +1 -1
  7. package/dist/auth-email-password/i18n.js +1 -0
  8. package/dist/auth-email-password/mail-notifications.js +1 -1
  9. package/dist/auth-email-password/web/auth-client.js +1 -1
  10. package/dist/auth-email-password/web/auth-form-primitives.js +1 -1
  11. package/dist/auth-email-password/web/client-actions.d.ts +2 -0
  12. package/dist/auth-email-password/web/client-actions.js +23 -0
  13. package/dist/auth-email-password/web/client-plugin.d.ts +2 -1
  14. package/dist/auth-email-password/web/client-plugin.js +2 -0
  15. package/dist/auth-email-password/web/default-topbar-actions.js +1 -1
  16. package/dist/auth-email-password/web/index.js +1 -1
  17. package/dist/auth-email-password/web/session.js +1 -1
  18. package/dist/auth-email-password/web/tenant-menu-items.js +1 -1
  19. package/dist/auth-email-password/web/tenant-switcher.js +1 -1
  20. package/dist/auth-email-password/web/user-menu.js +1 -1
  21. package/dist/auth-foundation/env.d.ts +9 -0
  22. package/dist/auth-foundation/env.js +21 -0
  23. package/dist/auth-foundation/index.d.ts +1 -0
  24. package/dist/auth-foundation/index.js +1 -0
  25. package/dist/auth-mfa/schema/user-mfa.d.ts +6 -6
  26. package/dist/billing-foundation/consumer-protection/confirmation-mail.js +1 -1
  27. package/dist/billing-foundation/consumer-protection/termination-pages.js +1 -1
  28. package/dist/billing-foundation/contact-card.d.ts +2 -0
  29. package/dist/billing-foundation/contact-card.js +16 -0
  30. package/dist/billing-foundation/entities.d.ts +8 -8
  31. package/dist/billing-foundation/handlers/billing-plans.query.js +1 -1
  32. package/dist/billing-foundation/index.d.ts +1 -1
  33. package/dist/billing-foundation/plan-catalog.js +1 -0
  34. package/dist/billing-foundation/projection.d.ts +8 -8
  35. package/dist/billing-foundation/types.d.ts +11 -0
  36. package/dist/billing-foundation/validate-options.js +13 -0
  37. package/dist/billing-foundation/web/billing-contact-plan.d.ts +5 -0
  38. package/dist/billing-foundation/web/billing-contact-plan.js +17 -0
  39. package/dist/billing-foundation/web/billing-plans-panel.js +5 -13
  40. package/dist/billing-foundation/web/cancel-contract-dialog.js +2 -2
  41. package/dist/billing-foundation/web/checkout-consent-dialog.d.ts +1 -1
  42. package/dist/billing-foundation/web/checkout-consent-dialog.js +1 -0
  43. package/dist/billing-foundation/web/client-plugin.d.ts +1 -1
  44. package/dist/billing-foundation/web/localized-params.d.ts +1 -0
  45. package/dist/billing-foundation/web/localized-params.js +11 -0
  46. package/dist/cap-counter/entity.d.ts +1 -1
  47. package/dist/cap-overview/handlers/tenant-caps-list.query.d.ts +9 -4
  48. package/dist/cap-overview/handlers/tenant-caps-list.query.js +14 -61
  49. package/dist/cap-overview/i18n.js +0 -2
  50. package/dist/cap-overview/web/cap-cards-panel.js +3 -3
  51. package/dist/cap-overview/web/client-plugin.d.ts +1 -1
  52. package/dist/channel-email/email-channel.d.ts +2 -1
  53. package/dist/compliance-profiles/web/client-plugin.d.ts +1 -1
  54. package/dist/config/feature.js +2 -2
  55. package/dist/config/table.d.ts +6 -6
  56. package/dist/custom-fields/entity.d.ts +4 -4
  57. package/dist/custom-fields/web/client-plugin.d.ts +1 -1
  58. package/dist/data-retention/schema/tenant-retention-override.d.ts +4 -4
  59. package/dist/delivery/tables.d.ts +6 -6
  60. package/dist/delivery/unsubscribe-page.js +1 -1
  61. package/dist/delivery/web/client-plugin.d.ts +1 -1
  62. package/dist/delivery/web/delivery-status-cell.js +1 -1
  63. package/dist/document-ingest-foundation/entity.d.ts +4 -4
  64. package/dist/feature-toggles/web/client-plugin.d.ts +1 -1
  65. package/dist/file-provider-s3/env-named-feature.d.ts +17 -0
  66. package/dist/file-provider-s3/env-named-feature.js +23 -0
  67. package/dist/file-provider-s3/env-schema.d.ts +7 -0
  68. package/dist/file-provider-s3/env-schema.js +15 -10
  69. package/dist/file-provider-s3/index.d.ts +1 -0
  70. package/dist/file-provider-s3/index.js +1 -0
  71. package/dist/folders/entity.d.ts +8 -8
  72. package/dist/folders/web/client-plugin.d.ts +1 -1
  73. package/dist/form-draft/entity.d.ts +2 -2
  74. package/dist/host-routing/constants.d.ts +2 -0
  75. package/dist/host-routing/constants.js +3 -0
  76. package/dist/host-routing/dispatch.d.ts +1 -1
  77. package/dist/host-routing/dispatch.js +35 -25
  78. package/dist/host-routing/feature.js +100 -27
  79. package/dist/host-routing/host.d.ts +9 -0
  80. package/dist/host-routing/host.js +24 -2
  81. package/dist/host-routing/index.d.ts +2 -1
  82. package/dist/host-routing/index.js +1 -0
  83. package/dist/host-routing/options.d.ts +14 -0
  84. package/dist/host-routing/redirects.d.ts +6 -0
  85. package/dist/host-routing/redirects.js +27 -0
  86. package/dist/host-routing/tenant-lookup.d.ts +7 -0
  87. package/dist/host-routing/tenant-lookup.js +39 -0
  88. package/dist/host-routing/validate-options.js +34 -1
  89. package/dist/inbound-mail-foundation/entities.d.ts +25 -25
  90. package/dist/inbound-mail-foundation/feature.js +7 -0
  91. package/dist/inbound-mail-foundation/i18n.d.ts +3 -0
  92. package/dist/inbound-mail-foundation/i18n.js +35 -0
  93. package/dist/inbound-mail-foundation/index.d.ts +1 -0
  94. package/dist/inbound-mail-foundation/index.js +1 -0
  95. package/dist/inbound-mail-foundation/projection.d.ts +20 -20
  96. package/dist/inbound-mail-foundation/screens.d.ts +7 -0
  97. package/dist/inbound-mail-foundation/screens.js +86 -0
  98. package/dist/inbound-mail-foundation/types.d.ts +2 -2
  99. package/dist/jobs/job-run-table.d.ts +6 -6
  100. package/dist/jobs/web/client-plugin.d.ts +1 -1
  101. package/dist/ledger/entity.d.ts +13 -13
  102. package/dist/legal-pages/feature.js +11 -1
  103. package/dist/legal-pages/layout.d.ts +2 -2
  104. package/dist/legal-pages/layout.js +4 -4
  105. package/dist/legal-pages/web/client-plugin.d.ts +1 -1
  106. package/dist/mail-foundation/feature.d.ts +20 -21
  107. package/dist/mail-foundation/feature.js +1 -1
  108. package/dist/managed-pages/table.d.ts +12 -12
  109. package/dist/managed-pages/web/client-plugin.d.ts +1 -1
  110. package/dist/notes-history/entity.d.ts +12 -12
  111. package/dist/notes-history/web/client-plugin.d.ts +1 -1
  112. package/dist/page-render/branding.js +1 -1
  113. package/dist/page-render/layout.d.ts +1 -1
  114. package/dist/page-render/layout.js +2 -2
  115. package/dist/page-render/markdown.js +1 -1
  116. package/dist/personal-access-tokens/i18n.js +1 -1
  117. package/dist/personal-access-tokens/schema/api-token.d.ts +6 -6
  118. package/dist/personal-access-tokens/screens.js +1 -1
  119. package/dist/secrets/feature.d.ts +1 -1
  120. package/dist/secrets/feature.js +3 -3
  121. package/dist/secrets/table.d.ts +1 -1
  122. package/dist/seo/sitemap.js +1 -1
  123. package/dist/sessions/schema/user-session.d.ts +4 -4
  124. package/dist/shared/mail-template-body.js +1 -1
  125. package/dist/shared/purge-files-reaching-subject.js +98 -3
  126. package/dist/tags/entity.d.ts +9 -9
  127. package/dist/tags/web/client-plugin.d.ts +1 -1
  128. package/dist/template-resolver/document-blocks.d.ts +5 -0
  129. package/dist/template-resolver/document-blocks.js +21 -0
  130. package/dist/template-resolver/document-ops.d.ts +167 -0
  131. package/dist/template-resolver/document-ops.js +390 -0
  132. package/dist/template-resolver/document-sanitizer.d.ts +3 -0
  133. package/dist/template-resolver/document-sanitizer.js +6 -0
  134. package/dist/template-resolver/feature.d.ts +2 -0
  135. package/dist/template-resolver/feature.js +4 -0
  136. package/dist/template-resolver/handlers/bundle-import.js +1 -0
  137. package/dist/template-resolver/handlers/document-base.d.ts +23 -0
  138. package/dist/template-resolver/handlers/document-base.js +55 -0
  139. package/dist/template-resolver/handlers/document-blocks.query.d.ts +22 -0
  140. package/dist/template-resolver/handlers/document-blocks.query.js +46 -0
  141. package/dist/template-resolver/handlers/export-bundle.query.d.ts +1 -0
  142. package/dist/template-resolver/handlers/export-bundle.query.js +9 -2
  143. package/dist/template-resolver/handlers/import-bundle-system.write.d.ts +1 -0
  144. package/dist/template-resolver/handlers/import-bundle.write.d.ts +1 -0
  145. package/dist/template-resolver/handlers/patch-draft.write.d.ts +66 -0
  146. package/dist/template-resolver/handlers/patch-draft.write.js +105 -0
  147. package/dist/template-resolver/handlers/preview.query.d.ts +1 -0
  148. package/dist/template-resolver/handlers/preview.query.js +2 -1
  149. package/dist/template-resolver/handlers/send-test.write.d.ts +1 -0
  150. package/dist/template-resolver/handlers/send-test.write.js +2 -1
  151. package/dist/template-resolver/handlers/template-write.d.ts +2 -0
  152. package/dist/template-resolver/handlers/template-write.js +14 -2
  153. package/dist/template-resolver/handlers/validate.query.d.ts +10 -4
  154. package/dist/template-resolver/handlers/validate.query.js +12 -7
  155. package/dist/template-resolver/{web/placeholder-text.js → placeholder-text.js} +1 -1
  156. package/dist/template-resolver/qualified-names.d.ts +2 -0
  157. package/dist/template-resolver/qualified-names.js +2 -0
  158. package/dist/template-resolver/seeding.d.ts +1 -0
  159. package/dist/template-resolver/seeding.js +2 -1
  160. package/dist/template-resolver/table.d.ts +10 -10
  161. package/dist/template-resolver/template-bundle.d.ts +1 -0
  162. package/dist/template-resolver/template-bundle.js +6 -1
  163. package/dist/template-resolver/template-preview.d.ts +4 -1
  164. package/dist/template-resolver/template-preview.js +16 -3
  165. package/dist/template-resolver/template-validation.d.ts +2 -1
  166. package/dist/template-resolver/template-validation.js +1 -1
  167. package/dist/template-resolver/user-content-table.d.ts +10 -10
  168. package/dist/template-resolver/web/client-plugin.d.ts +1 -1
  169. package/dist/template-resolver/web/client-plugin.js +3 -3
  170. package/dist/template-resolver/web/document-tiptap-editor.js +1 -1
  171. package/dist/template-resolver/web/document-toolbar-blocks.d.ts +2 -5
  172. package/dist/template-resolver/web/document-toolbar-blocks.js +1 -21
  173. package/dist/template-resolver/web/i18n.js +14 -0
  174. package/dist/template-resolver/web/placeholder-line-editor.js +2 -2
  175. package/dist/tenant/invitation-table.d.ts +6 -6
  176. package/dist/tenant/membership-table.d.ts +4 -4
  177. package/dist/tenant/schema/tenant.d.ts +6 -6
  178. package/dist/tenant/web/client-plugin.d.ts +1 -1
  179. package/dist/tenant/web/member-status-cell.js +1 -1
  180. package/dist/tier-engine/entity.d.ts +1 -1
  181. package/dist/tier-engine/tier-resolver.d.ts +1 -1
  182. package/dist/tier-engine/web/client-plugin.d.ts +1 -1
  183. package/dist/user/schema/user.d.ts +12 -12
  184. package/dist/user-data-rights/env.d.ts +5 -0
  185. package/dist/user-data-rights/env.js +11 -0
  186. package/dist/user-data-rights/feature.js +4 -2
  187. package/dist/user-data-rights/index.d.ts +1 -0
  188. package/dist/user-data-rights/index.js +1 -0
  189. package/dist/user-data-rights/lib/mail-transport-resolver.js +1 -1
  190. package/dist/user-data-rights/lib/storage-provider-resolver.js +1 -1
  191. package/dist/user-data-rights/mail-notifications.js +1 -1
  192. package/dist/user-data-rights/schema/download-attempt.d.ts +14 -14
  193. package/dist/user-data-rights/schema/download-token.d.ts +8 -8
  194. package/dist/user-data-rights/schema/export-job.d.ts +6 -6
  195. package/dist/user-data-rights/web/client-plugin.d.ts +1 -1
  196. package/dist/user-data-rights/web/privacy-center-screen.js +2 -2
  197. package/dist/waitlist/entity.d.ts +14 -14
  198. package/dist/waitlist/legacy-migrate.d.ts +2 -0
  199. package/dist/waitlist/legacy-migrate.js +38 -3
  200. package/package.json +16 -11
  201. package/src/account-security/changes.json +6 -0
  202. package/src/auth-email-password/changes.json +6 -0
  203. package/src/auth-foundation/changes.json +6 -0
  204. package/src/billing-foundation/changes.json +6 -0
  205. package/src/cap-overview/changes.json +7 -0
  206. package/src/channel-email/changes.json +6 -0
  207. package/src/crypto-shredding/changes.json +6 -0
  208. package/src/file-provider-s3/changes.json +6 -0
  209. package/src/host-routing/changes.json +6 -0
  210. package/src/inbound-mail-foundation/changes.json +7 -0
  211. package/src/legal-pages/changes.json +6 -0
  212. package/src/template-resolver/changes.json +18 -0
  213. package/src/user-data-rights/changes.json +6 -0
  214. package/src/waitlist/changes.json +6 -0
  215. /package/dist/template-resolver/{web/placeholder-text.d.ts → placeholder-text.d.ts} +0 -0
@@ -0,0 +1,23 @@
1
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { featureWithEnvSchema } from "@cosmicdrift/kumiko-framework/env";
3
+ import { createRuntimeS3EnvSchema } from "./env-schema.js";
4
+ import { createS3ProviderFromEnv } from "./lib/env-helper.js";
5
+ /** An additional S3 provider next to `"s3-env"`, reading its credentials from `<envPrefix>BUCKET` etc. */
6
+ export function createFileProviderS3NamedEnvFeature(options) {
7
+ const envSchema = createRuntimeS3EnvSchema(options.envPrefix);
8
+ const feature = defineFeature(`file-provider-s3-env-${options.name}`, (r) => {
9
+ r.describe(`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).`);
10
+ r.uiHints({
11
+ displayLabel: `File Provider · S3 (${options.name})`,
12
+ category: "storage",
13
+ recommended: false,
14
+ });
15
+ r.requires({ features: ["file-foundation"] });
16
+ r.envSchema(envSchema);
17
+ const plugin = {
18
+ build: async (_ctx, _tenantId) => createS3ProviderFromEnv(options.envPrefix), // @wrapper-known semantic-alias
19
+ };
20
+ r.useExtension("fileProvider", options.name, plugin);
21
+ }, { dedupeOptions: { name: options.name, envPrefix: options.envPrefix } });
22
+ return featureWithEnvSchema(feature, envSchema);
23
+ }
@@ -9,6 +9,13 @@ export declare const s3EndpointSchema: z.ZodURL;
9
9
  export declare function createS3EnvSchema<const TPrefix extends string>(options: {
10
10
  readonly prefix: TPrefix;
11
11
  }): z.ZodObject<S3EnvShape<TPrefix> extends infer T ? { -readonly [P in keyof T]: T[P]; } : never, z.core.$strip>;
12
+ /** Same block for a prefix only known at runtime (named providers); keys are typed as a record. */
13
+ export declare function createRuntimeS3EnvSchema(prefix: string): z.ZodObject<{
14
+ [x: string]: z.ZodOptional<z.ZodEnum<{
15
+ false: "false";
16
+ true: "true";
17
+ }>> | z.ZodOptional<z.ZodString> | z.ZodOptional<z.ZodUnion<readonly [z.ZodPipe<z.ZodLiteral<"">, z.ZodTransform<undefined, "">>, z.ZodURL]>>;
18
+ }, z.core.$strip>;
12
19
  type OptionalString = z.ZodOptional<z.ZodString>;
13
20
  type S3EnvShape<TPrefix extends string> = {
14
21
  [Key in `${TPrefix}${"BUCKET" | "REGION" | "ACCESS_KEY" | "SECRET_KEY"}`]: OptionalString;
@@ -15,15 +15,8 @@ function isAcceptableEndpoint(value) {
15
15
  export const s3EndpointSchema = z
16
16
  .url({ protocol: /^https?$/ })
17
17
  .refine(isAcceptableEndpoint, "must use https unless the host is loopback");
18
- /** Builds the env block `createS3ProviderFromEnv(prefix)` reads, e.g.
19
- * `createS3EnvSchema({ prefix: "RELEASES_S3_" })` for a second bucket.
20
- * Every field is optional: an app that mounts the env provider next to
21
- * another one picks the provider at runtime, and the provider reports
22
- * missing credentials on its first file operation. */
23
- export function createS3EnvSchema(options) {
24
- const { prefix } = options;
25
- // @cast-boundary schema-walk — computed template keys widen to an index signature; the literal keys below are exactly the ones S3EnvShape names.
26
- const shape = {
18
+ function buildS3Shape(prefix) {
19
+ return {
27
20
  [`${prefix}BUCKET`]: z.string().min(1).optional().describe("S3 bucket."),
28
21
  [`${prefix}REGION`]: z.string().min(1).optional().describe("S3 region."),
29
22
  [`${prefix}ACCESS_KEY`]: z
@@ -48,7 +41,19 @@ export function createS3EnvSchema(options) {
48
41
  .optional()
49
42
  .describe('Path-style bucket addressing ("true"), needed by MinIO and some S3 clones.'),
50
43
  };
51
- return z.object(shape);
44
+ }
45
+ /** Builds the env block `createS3ProviderFromEnv(prefix)` reads, e.g.
46
+ * `createS3EnvSchema({ prefix: "RELEASES_S3_" })` for a second bucket.
47
+ * Every field is optional: an app that mounts the env provider next to
48
+ * another one picks the provider at runtime, and the provider reports
49
+ * missing credentials on its first file operation. */
50
+ export function createS3EnvSchema(options) {
51
+ // @cast-boundary schema-walk — computed template keys widen to an index signature; the literal keys below are exactly the ones S3EnvShape names.
52
+ return z.object(buildS3Shape(options.prefix));
53
+ }
54
+ /** Same block for a prefix only known at runtime (named providers); keys are typed as a record. */
55
+ export function createRuntimeS3EnvSchema(prefix) {
56
+ return z.object(buildS3Shape(prefix));
52
57
  }
53
58
  /** The `S3_*` block of the `"s3-env"` provider. */
54
59
  export const s3EnvSchema = createS3EnvSchema({ prefix: "S3_" });
@@ -1,2 +1,3 @@
1
+ export { createFileProviderS3NamedEnvFeature, type NamedS3EnvProviderOptions, } from "./env-named-feature.ts";
1
2
  export { createS3EnvSchema, s3EndpointSchema, s3EnvSchema } from "./env-schema.ts";
2
3
  export { createFileProviderS3Feature, type FileProviderS3FeatureOptions, S3_SECRET_ACCESS_KEY, } from "./feature.ts";
@@ -1,3 +1,4 @@
1
1
  // Public API of the file-provider-s3 bundled-feature.
2
+ export { createFileProviderS3NamedEnvFeature, } from "./env-named-feature.js";
2
3
  export { createS3EnvSchema, s3EndpointSchema, s3EnvSchema } from "./env-schema.js";
3
4
  export { createFileProviderS3Feature, S3_SECRET_ACCESS_KEY, } from "./feature.js";
@@ -14,7 +14,7 @@ export declare const folderEntity: EntityDefinition<{
14
14
  readonly sensitive?: boolean;
15
15
  readonly writeOnly?: boolean;
16
16
  readonly readAsInstruction?: boolean;
17
- readonly format?: "email" | "url" | "phone" | "password";
17
+ readonly format?: "email" | "url" | "phone" | "password" | "month";
18
18
  readonly default?: string;
19
19
  readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
20
20
  readonly multiline?: boolean | {
@@ -37,7 +37,7 @@ export declare const folderEntity: EntityDefinition<{
37
37
  readonly sensitive?: boolean;
38
38
  readonly writeOnly?: boolean;
39
39
  readonly readAsInstruction?: boolean;
40
- readonly format?: "email" | "url" | "phone" | "password";
40
+ readonly format?: "email" | "url" | "phone" | "password" | "month";
41
41
  readonly default?: string;
42
42
  readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
43
43
  readonly multiline?: boolean | {
@@ -62,7 +62,7 @@ export declare function createFolderAssignmentEntity(access?: EntityDefinition["
62
62
  readonly sensitive?: boolean;
63
63
  readonly writeOnly?: boolean;
64
64
  readonly readAsInstruction?: boolean;
65
- readonly format?: "email" | "url" | "phone" | "password";
65
+ readonly format?: "email" | "url" | "phone" | "password" | "month";
66
66
  readonly default?: string;
67
67
  readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
68
68
  readonly multiline?: boolean | {
@@ -85,7 +85,7 @@ export declare function createFolderAssignmentEntity(access?: EntityDefinition["
85
85
  readonly sensitive?: boolean;
86
86
  readonly writeOnly?: boolean;
87
87
  readonly readAsInstruction?: boolean;
88
- readonly format?: "email" | "url" | "phone" | "password";
88
+ readonly format?: "email" | "url" | "phone" | "password" | "month";
89
89
  readonly default?: string;
90
90
  readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
91
91
  readonly multiline?: boolean | {
@@ -108,7 +108,7 @@ export declare function createFolderAssignmentEntity(access?: EntityDefinition["
108
108
  readonly sensitive?: boolean;
109
109
  readonly writeOnly?: boolean;
110
110
  readonly readAsInstruction?: boolean;
111
- readonly format?: "email" | "url" | "phone" | "password";
111
+ readonly format?: "email" | "url" | "phone" | "password" | "month";
112
112
  readonly default?: string;
113
113
  readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
114
114
  readonly multiline?: boolean | {
@@ -133,7 +133,7 @@ export declare const folderAssignmentEntity: EntityDefinition<{
133
133
  readonly sensitive?: boolean;
134
134
  readonly writeOnly?: boolean;
135
135
  readonly readAsInstruction?: boolean;
136
- readonly format?: "email" | "url" | "phone" | "password";
136
+ readonly format?: "email" | "url" | "phone" | "password" | "month";
137
137
  readonly default?: string;
138
138
  readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
139
139
  readonly multiline?: boolean | {
@@ -156,7 +156,7 @@ export declare const folderAssignmentEntity: EntityDefinition<{
156
156
  readonly sensitive?: boolean;
157
157
  readonly writeOnly?: boolean;
158
158
  readonly readAsInstruction?: boolean;
159
- readonly format?: "email" | "url" | "phone" | "password";
159
+ readonly format?: "email" | "url" | "phone" | "password" | "month";
160
160
  readonly default?: string;
161
161
  readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
162
162
  readonly multiline?: boolean | {
@@ -179,7 +179,7 @@ export declare const folderAssignmentEntity: EntityDefinition<{
179
179
  readonly sensitive?: boolean;
180
180
  readonly writeOnly?: boolean;
181
181
  readonly readAsInstruction?: boolean;
182
- readonly format?: "email" | "url" | "phone" | "password";
182
+ readonly format?: "email" | "url" | "phone" | "password" | "month";
183
183
  readonly default?: string;
184
184
  readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
185
185
  readonly multiline?: boolean | {
@@ -1,2 +1,2 @@
1
- import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
1
+ import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer/web";
2
2
  export declare function foldersClient(): ClientFeatureDefinition;
@@ -13,7 +13,7 @@ export declare const formDraftEntity: import("@cosmicdrift/kumiko-types/fields")
13
13
  readonly sensitive?: boolean;
14
14
  readonly writeOnly?: boolean;
15
15
  readonly readAsInstruction?: boolean;
16
- readonly format?: "email" | "url" | "phone" | "password";
16
+ readonly format?: "email" | "url" | "phone" | "password" | "month";
17
17
  readonly default?: string;
18
18
  readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
19
19
  readonly multiline?: boolean | {
@@ -36,7 +36,7 @@ export declare const formDraftEntity: import("@cosmicdrift/kumiko-types/fields")
36
36
  readonly sensitive?: boolean;
37
37
  readonly writeOnly?: boolean;
38
38
  readonly readAsInstruction?: boolean;
39
- readonly format?: "email" | "url" | "phone" | "password";
39
+ readonly format?: "email" | "url" | "phone" | "password" | "month";
40
40
  readonly default?: string;
41
41
  readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
42
42
  readonly multiline?: boolean | {
@@ -1,8 +1,10 @@
1
1
  export declare const HOST_ROUTING_FEATURE = "host-routing";
2
2
  export declare const HostRoutingQueries: {
3
3
  readonly tenantKeyExists: "host-routing:query:tenant-key-exists";
4
+ readonly hostTenantExists: "host-routing:query:host-tenant-exists";
4
5
  };
5
6
  export declare const TENANT_KEY_MAX_LENGTH = 50;
6
7
  export declare const TENANT_KEY_PATTERN: RegExp;
7
8
  export declare function isTenantKeyCandidate(value: string): boolean;
8
9
  export declare function isPathTenantKeyCandidate(value: string): boolean;
10
+ export declare const HOST_HEADER_MAX_LENGTH = 260;
@@ -1,6 +1,7 @@
1
1
  export const HOST_ROUTING_FEATURE = "host-routing";
2
2
  export const HostRoutingQueries = {
3
3
  tenantKeyExists: `${HOST_ROUTING_FEATURE}:query:tenant-key-exists`,
4
+ hostTenantExists: `${HOST_ROUTING_FEATURE}:query:host-tenant-exists`,
4
5
  };
5
6
  // Matches tenant:write:create's key limit; keeps lookups for absurd keys out
6
7
  // of the database.
@@ -17,3 +18,5 @@ const PATH_TENANT_KEY_PATTERN = /^[A-Za-z0-9._~-]+$/;
17
18
  export function isPathTenantKeyCandidate(value) {
18
19
  return value.length <= TENANT_KEY_MAX_LENGTH && PATH_TENANT_KEY_PATTERN.test(value);
19
20
  }
21
+ // Longest Host header value a hostname plus port can take.
22
+ export const HOST_HEADER_MAX_LENGTH = 260;
@@ -1,4 +1,4 @@
1
- import type { PageHeadSystemQuery } from "@cosmicdrift/kumiko-headless/apex";
1
+ import type { PageHeadSystemQuery } from "@cosmicdrift/kumiko-renderer/headless/apex";
2
2
  import type { HostRoutingOptions } from "./options.ts";
3
3
  export type HostRoutingDispatchResult = {
4
4
  readonly kind: "html";
@@ -2,7 +2,8 @@ import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { RateLimitError, ValidationError } from "@cosmicdrift/kumiko-framework/errors";
3
3
  import * as z from "zod";
4
4
  import { HostRoutingQueries, isPathTenantKeyCandidate, isTenantKeyCandidate } from "./constants.js";
5
- import { classifyHost } from "./host.js";
5
+ import { classifyHost, splitFirstSegment, tenantKeyFromPath } from "./host.js";
6
+ import { matchRedirect, sameOriginPath } from "./redirects.js";
6
7
  import { validateHostRoutingOptions } from "./validate-options.js";
7
8
  const tenantKeyExistsResultSchema = z.object({ exists: z.boolean() });
8
9
  async function isTenantKeyKnown(systemQuery, key) {
@@ -21,6 +22,23 @@ async function isTenantKeyKnown(systemQuery, key) {
21
22
  }
22
23
  return tenantKeyExistsResultSchema.parse(result).exists;
23
24
  }
25
+ async function isHostKnown(systemQuery, host) {
26
+ let result;
27
+ try {
28
+ result = await systemQuery(HostRoutingQueries.hostTenantExists, { host }, SYSTEM_TENANT_ID);
29
+ }
30
+ catch (error) {
31
+ if (error instanceof ValidationError)
32
+ return false;
33
+ // The shell is harmless: the API resolver is authoritative and answers
34
+ // tenant_required for unknown hosts, while a 404 would hit visitors behind
35
+ // a shared NAT under load.
36
+ if (error instanceof RateLimitError)
37
+ return true;
38
+ throw error;
39
+ }
40
+ return tenantKeyExistsResultSchema.parse(result).exists;
41
+ }
24
42
  function serve(surface, file = surface.file) {
25
43
  return surface.csp === undefined
26
44
  ? { kind: "html", file }
@@ -33,19 +51,6 @@ function notFound(surface) {
33
51
  ? { kind: "not-found", file: surface.file }
34
52
  : { kind: "not-found", file: surface.file, csp: surface.csp };
35
53
  }
36
- // Redirect targets are always same-origin paths. Collapsing leading slashes
37
- // and backslashes keeps "/de//evil.example" from turning into the
38
- // protocol-relative "//evil.example".
39
- function sameOriginPath(path) {
40
- return `/${path.replace(/^[/\\]+/, "")}`;
41
- }
42
- function splitFirstSegment(path) {
43
- const withoutLeading = path.replace(/^\/+/, "");
44
- const slash = withoutLeading.indexOf("/");
45
- if (slash === -1)
46
- return { segment: withoutLeading, rest: "/" };
47
- return { segment: withoutLeading.slice(0, slash), rest: withoutLeading.slice(slash) };
48
- }
49
54
  function apexPage(options, req) {
50
55
  const { apex, locales } = options;
51
56
  if (locales === undefined)
@@ -57,28 +62,26 @@ function apexPage(options, req) {
57
62
  const locale = locales.prefixed.includes(segment) ? segment : locales.default;
58
63
  return serve(apex, apex.localeFiles?.[locale] ?? apex.file);
59
64
  }
60
- async function tenantPage(options, key, systemQuery) {
65
+ async function tenantPage(options, key, systemQuery, keySource) {
61
66
  // No key (bare tenant prefix): the shell handles login and tenant choice.
62
67
  if (key === null)
63
68
  return serve(options.tenant);
64
- const isCandidate = options.tenantBy === "path" ? isPathTenantKeyCandidate : isTenantKeyCandidate;
69
+ const isCandidate = keySource === "path" ? isPathTenantKeyCandidate : isTenantKeyCandidate;
65
70
  if (!isCandidate(key))
66
71
  return notFound(options.unknownTenant);
67
72
  return (await isTenantKeyKnown(systemQuery, key))
68
73
  ? serve(options.tenant)
69
74
  : notFound(options.unknownTenant);
70
75
  }
71
- function tenantKeyFromPath(prefix, path) {
72
- if (path !== prefix && !path.startsWith(`${prefix}/`))
73
- return undefined;
74
- const { segment } = splitFirstSegment(path.slice(prefix.length));
75
- return segment === "" ? null : segment;
76
- }
77
76
  // The routing decision for runProdApp's `hostDispatch` (and, via
78
77
  // toDevHostDispatch, the dev server). Unknown hosts fail closed with 404.
79
78
  export function createHostDispatch(options) {
80
79
  validateHostRoutingOptions(options);
80
+ const redirects = options.redirects ?? [];
81
81
  return async (req, { systemQuery }) => {
82
+ const redirect = matchRedirect(redirects, req.host, req.path, req.search);
83
+ if (redirect !== undefined)
84
+ return { kind: "redirect", ...redirect };
82
85
  const host = classifyHost(req.host, options);
83
86
  switch (host.kind) {
84
87
  case "fixed":
@@ -88,12 +91,19 @@ export function createHostDispatch(options) {
88
91
  case "subdomain":
89
92
  if (options.tenantBy !== "host")
90
93
  return notFound(undefined);
91
- return tenantPage(options, host.key, systemQuery);
94
+ return tenantPage(options, host.key, systemQuery, "subdomain");
95
+ case "resolved": {
96
+ if (await isHostKnown(systemQuery, host.hostname))
97
+ return serve(options.tenant);
98
+ return notFound(host.hostname.endsWith(`.${options.baseDomain.toLowerCase()}`)
99
+ ? options.unknownTenant
100
+ : undefined);
101
+ }
92
102
  case "apex": {
93
- if (options.tenantBy === "path") {
103
+ if (options.tenantPathPrefix !== undefined) {
94
104
  const key = tenantKeyFromPath(options.tenantPathPrefix, req.path);
95
105
  if (key !== undefined)
96
- return tenantPage(options, key, systemQuery);
106
+ return tenantPage(options, key, systemQuery, "path");
97
107
  }
98
108
  return apexPage(options, req);
99
109
  }
@@ -1,31 +1,70 @@
1
- import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
2
1
  import { defineFeature, defineQueryHandler, isSystemTenant, } from "@cosmicdrift/kumiko-framework/engine";
3
2
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
4
3
  import * as z from "zod";
5
4
  import { EXT_TENANT_EXISTENCE, EXT_TENANT_RESOLVER, } from "../auth-foundation/index.js";
6
- import { isTenantServingPublicContent, tenantTable, } from "../tenant/index.js";
7
- import { HOST_ROUTING_FEATURE, TENANT_KEY_MAX_LENGTH } from "./constants.js";
8
- import { classifyHost } from "./host.js";
5
+ import { HOST_HEADER_MAX_LENGTH, HOST_ROUTING_FEATURE, TENANT_KEY_MAX_LENGTH, } from "./constants.js";
6
+ import { classifyHost, normalizeHostname } from "./host.js";
7
+ import { isTenantServing, servingTenantIdByKey, servingTenantIdByResolver, } from "./tenant-lookup.js";
9
8
  import { validateHostRoutingOptions } from "./validate-options.js";
10
- // Disabled tenants and tenants in any destroy phase are not routable, the same
11
- // rule public files and pages follow.
12
- async function servingTenantIdByKey(db, key) {
13
- const row = await fetchOne(db, tenantTable, { key });
14
- return row !== undefined && isTenantServingPublicContent(row) ? row.id : null;
15
- }
16
- async function isTenantServing(db, id) {
17
- const row = await fetchOne(db, tenantTable, { id });
18
- return row !== undefined && isTenantServingPublicContent(row);
19
- }
20
9
  // auth-foundation types the Hono context as unknown to stay free of a hono dep.
21
- function isHostHeaderReader(c) {
10
+ function isRequestReader(c) {
22
11
  return (typeof c === "object" &&
23
12
  c !== null &&
24
13
  "req" in c &&
25
14
  typeof c.req === "object" &&
26
15
  c.req !== null &&
27
16
  "header" in c.req &&
28
- typeof c.req.header === "function");
17
+ typeof c.req.header === "function" &&
18
+ "url" in c.req &&
19
+ typeof c.req.url === "string" &&
20
+ "path" in c.req &&
21
+ typeof c.req.path === "string");
22
+ }
23
+ // Hono routes on the decoded path and ignores a trailing slash, so the
24
+ // allowlist must compare the same shape: "/api/%71uery" and "/api/query/"
25
+ // reach the query handler too.
26
+ function routedPathOf(c) {
27
+ return c.req.path.length > 1 ? c.req.path.replace(/\/+$/, "") : c.req.path;
28
+ }
29
+ // "example.com" and "www.example.com" are one site: a host without its own
30
+ // entry falls back to the other variant's entry instead of keeping the anchor.
31
+ function allowlistFor(options, hostname) {
32
+ const allowlists = options.apexAnonymousQueryAllowlist;
33
+ if (allowlists === undefined)
34
+ return undefined;
35
+ const counterpart = hostname.startsWith("www.") ? hostname.slice(4) : `www.${hostname}`;
36
+ for (const key of [hostname, counterpart]) {
37
+ if (Object.hasOwn(allowlists, key))
38
+ return allowlists[key];
39
+ }
40
+ return undefined;
41
+ }
42
+ // Both carry { type, payload } and run read handlers; /api/batch is commands only.
43
+ const TYPED_READ_PATHS = new Set(["/api/query", "/api/stream"]);
44
+ const queryTypeBodySchema = z.object({ type: z.string() });
45
+ // Reads the query type from a clone, so the body stays intact for the handler.
46
+ async function peekQueryType(c) {
47
+ if (c.req.raw === undefined)
48
+ return null;
49
+ try {
50
+ const parsed = queryTypeBodySchema.safeParse(await c.req.raw.clone().json());
51
+ return parsed.success ? parsed.data.type : null;
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ }
57
+ // Apex anchor tenant for anonymous calls. A host with an allowlist only gets
58
+ // the anchor for the listed query types on read paths; other paths keep it.
59
+ async function apexAnchorFor(options, c, hostname) {
60
+ const anchor = options.apexAnonymousTenantId ?? null;
61
+ if (anchor === null)
62
+ return null;
63
+ const allowlist = allowlistFor(options, hostname);
64
+ if (allowlist === undefined || !TYPED_READ_PATHS.has(routedPathOf(c)))
65
+ return anchor;
66
+ const type = await peekQueryType(c);
67
+ return type !== null && allowlist.includes(type) ? anchor : null;
29
68
  }
30
69
  // Returns only { exists }: the same fact the tenant 404 page already makes
31
70
  // public, never other tenant fields. Rate-limited so it is no cheap oracle.
@@ -47,34 +86,66 @@ const tenantKeyExistsQuery = defineQueryHandler({
47
86
  return { exists: (await servingTenantIdByKey(db, query.payload.key)) !== null };
48
87
  },
49
88
  });
89
+ function hostTenantExistsQueryFor(options) {
90
+ return defineQueryHandler({
91
+ name: "host-tenant-exists",
92
+ schema: z.object({ host: z.string().min(1).max(HOST_HEADER_MAX_LENGTH) }),
93
+ access: { roles: ["anonymous"] },
94
+ rateLimit: { per: "ip+handler", limit: 120, windowSeconds: 60 },
95
+ // Page-routing plumbing, not a task an agent performs.
96
+ agent: { expose: false },
97
+ description: "Anonymous host check backing the unknown-host 404 of the host-routing hostDispatch in tenantBy resolver mode. Returns only { exists }.",
98
+ handler: async (query, ctx) => {
99
+ if (!ctx.systemDb) {
100
+ throw new InternalError({
101
+ message: "host-routing:query:host-tenant-exists requires ctx.systemDb (r.systemScope())",
102
+ });
103
+ }
104
+ const hostname = normalizeHostname(query.payload.host);
105
+ if (hostname === null)
106
+ return { exists: false };
107
+ const db = ctx.systemDb.acknowledgeCrossTenant("host existence check for the host-routing 404 — publicly observable via the 404 itself");
108
+ return { exists: (await servingTenantIdByResolver(options, db, hostname)) !== null };
109
+ },
110
+ });
111
+ }
50
112
  function tenantResolverFor(options) {
51
- const apexTenantId = options.apexAnonymousTenantId ?? null;
52
- if (options.tenantBy === "host") {
113
+ if (options.tenantBy !== "path") {
53
114
  return {
54
115
  trust: "authoritative",
55
116
  build: ({ db }) => async (c) => {
56
- if (!isHostHeaderReader(c))
117
+ if (!isRequestReader(c))
57
118
  return null;
58
- const host = classifyHost(c.req.header("Host") ?? "", options);
59
- if (host.kind === "subdomain")
60
- return servingTenantIdByKey(db, host.key);
119
+ const hostHeader = c.req.header("Host") ?? "";
120
+ const host = classifyHost(hostHeader, options);
61
121
  // Fixed and unknown hosts have no anonymous tenant: an authoritative
62
122
  // null answers tenant_required, whatever X-Tenant says.
63
- return host.kind === "apex" ? apexTenantId : null;
123
+ switch (host.kind) {
124
+ case "subdomain":
125
+ return servingTenantIdByKey(db, host.key);
126
+ case "resolved":
127
+ return servingTenantIdByResolver(options, db, host.hostname);
128
+ case "apex":
129
+ return apexAnchorFor(options, c, normalizeHostname(hostHeader) ?? "");
130
+ case "fixed":
131
+ case "unknown":
132
+ return null;
133
+ }
64
134
  },
65
135
  };
66
136
  }
67
- if (apexTenantId === null)
137
+ if (options.apexAnonymousTenantId === undefined)
68
138
  return undefined;
69
139
  // Path mode: the client names its tenant; the apex default only fills in
70
140
  // when it does not.
71
141
  return {
72
142
  trust: "fallback-only",
73
143
  build: () => async (c) => {
74
- if (!isHostHeaderReader(c))
144
+ if (!isRequestReader(c))
75
145
  return null;
76
- return classifyHost(c.req.header("Host") ?? "", options).kind === "apex"
77
- ? apexTenantId
146
+ const hostHeader = c.req.header("Host") ?? "";
147
+ return classifyHost(hostHeader, options).kind === "apex"
148
+ ? apexAnchorFor(options, c, normalizeHostname(hostHeader) ?? "")
78
149
  : null;
79
150
  },
80
151
  };
@@ -89,6 +160,8 @@ export function createHostRoutingFeature(options) {
89
160
  r.requires({ features: ["auth-foundation", "tenant"] });
90
161
  r.systemScope();
91
162
  r.queryHandler(tenantKeyExistsQuery);
163
+ if (typeof options.tenantBy === "function")
164
+ r.queryHandler(hostTenantExistsQueryFor(options));
92
165
  const resolver = tenantResolverFor(options);
93
166
  if (resolver !== undefined) {
94
167
  // biome-ignore lint/correctness/useHookAtTopLevel: r.useExtension is a registrar method, not a React hook; path mode without an apex tenant must leave the resolver slot free.
@@ -8,7 +8,16 @@ export type HostClass = {
8
8
  } | {
9
9
  readonly kind: "subdomain";
10
10
  readonly key: string;
11
+ } | {
12
+ readonly kind: "resolved";
13
+ readonly hostname: string;
11
14
  } | {
12
15
  readonly kind: "unknown";
13
16
  };
14
17
  export declare function classifyHost(host: string, options: HostRoutingOptions): HostClass;
18
+ export declare function splitFirstSegment(path: string): {
19
+ readonly segment: string;
20
+ readonly rest: string;
21
+ };
22
+ export declare function tenantKeyFromPath(prefix: string, path: string): string | null | undefined;
23
+ export declare function isApexHostname(hostname: string, baseDomain: string): boolean;
@@ -23,9 +23,11 @@ export function classifyHost(host, options) {
23
23
  : undefined;
24
24
  if (fixed !== undefined)
25
25
  return { kind: "fixed", surface: fixed };
26
- const baseDomain = options.baseDomain.toLowerCase();
27
- if (hostname === baseDomain || hostname === `www.${baseDomain}`)
26
+ if (isApexHostname(hostname, options.baseDomain))
28
27
  return { kind: "apex" };
28
+ const baseDomain = options.baseDomain.toLowerCase();
29
+ if (typeof options.tenantBy === "function")
30
+ return { kind: "resolved", hostname };
29
31
  const suffix = `.${baseDomain}`;
30
32
  if (!hostname.endsWith(suffix))
31
33
  return { kind: "unknown" };
@@ -35,3 +37,23 @@ export function classifyHost(host, options) {
35
37
  return { kind: "unknown" };
36
38
  return { kind: "subdomain", key: label };
37
39
  }
40
+ export function splitFirstSegment(path) {
41
+ const withoutLeading = path.replace(/^\/+/, "");
42
+ const slash = withoutLeading.indexOf("/");
43
+ if (slash === -1)
44
+ return { segment: withoutLeading, rest: "/" };
45
+ return { segment: withoutLeading.slice(0, slash), rest: withoutLeading.slice(slash) };
46
+ }
47
+ // undefined: path is outside the prefix; null: bare prefix without a key.
48
+ export function tenantKeyFromPath(prefix, path) {
49
+ if (path !== prefix && !path.startsWith(`${prefix}/`))
50
+ return undefined;
51
+ const { segment } = splitFirstSegment(path.slice(prefix.length));
52
+ return segment === "" ? null : segment;
53
+ }
54
+ // Apex hostnames are the only ones that may carry a path-named tenant or an
55
+ // anonymous anchor tenant.
56
+ export function isApexHostname(hostname, baseDomain) {
57
+ const base = baseDomain.toLowerCase();
58
+ return hostname === base || hostname === `www.${base}`;
59
+ }
@@ -2,4 +2,5 @@ export { HOST_ROUTING_FEATURE, HostRoutingQueries, isTenantKeyCandidate, TENANT_
2
2
  export { createHostDispatch, type HostRoutingDispatchFn, type HostRoutingDispatchResult, type HostRoutingRequest, } from "./dispatch.ts";
3
3
  export { createHostRoutingFeature } from "./feature.ts";
4
4
  export { normalizeHostname } from "./host.ts";
5
- export type { HostRoutingApexSurface, HostRoutingLocales, HostRoutingOptions, HostRoutingSurface, HostRoutingTenantMode, } from "./options.ts";
5
+ export type { HostRoutingApexSurface, HostRoutingLocales, HostRoutingOptions, HostRoutingRedirect, HostRoutingSurface, HostRoutingTenantMode, HostTenantResolver, } from "./options.ts";
6
+ export { createHostTenantIdResolver } from "./tenant-lookup.ts";
@@ -2,3 +2,4 @@ export { HOST_ROUTING_FEATURE, HostRoutingQueries, isTenantKeyCandidate, TENANT_
2
2
  export { createHostDispatch, } from "./dispatch.js";
3
3
  export { createHostRoutingFeature } from "./feature.js";
4
4
  export { normalizeHostname } from "./host.js";
5
+ export { createHostTenantIdResolver } from "./tenant-lookup.js";
@@ -1,3 +1,4 @@
1
+ import type { DbConnection, TenantDb } from "@cosmicdrift/kumiko-framework/db";
1
2
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
2
3
  export type HostRoutingSurface = {
3
4
  readonly file: string;
@@ -10,11 +11,22 @@ export type HostRoutingLocales = {
10
11
  readonly default: string;
11
12
  readonly prefixed: readonly string[];
12
13
  };
14
+ export type HostTenantResolver = (host: string, db: DbConnection | TenantDb) => Promise<TenantId | null> | TenantId | null;
13
15
  export type HostRoutingTenantMode = {
14
16
  readonly tenantBy: "host";
17
+ readonly tenantPathPrefix?: string;
15
18
  } | {
16
19
  readonly tenantBy: "path";
17
20
  readonly tenantPathPrefix: string;
21
+ } | {
22
+ readonly tenantBy: HostTenantResolver;
23
+ readonly tenantPathPrefix?: string;
24
+ };
25
+ export type HostRoutingRedirect = {
26
+ readonly from: string;
27
+ readonly to: string;
28
+ readonly host?: string;
29
+ readonly status?: 301 | 302;
18
30
  };
19
31
  export type HostRoutingOptions = HostRoutingTenantMode & {
20
32
  readonly baseDomain: string;
@@ -24,4 +36,6 @@ export type HostRoutingOptions = HostRoutingTenantMode & {
24
36
  readonly hosts?: Readonly<Record<string, HostRoutingSurface>>;
25
37
  readonly locales?: HostRoutingLocales;
26
38
  readonly apexAnonymousTenantId?: TenantId;
39
+ readonly apexAnonymousQueryAllowlist?: Readonly<Record<string, readonly string[]>>;
40
+ readonly redirects?: readonly HostRoutingRedirect[];
27
41
  };
@@ -0,0 +1,6 @@
1
+ import type { HostRoutingRedirect } from "./options.ts";
2
+ export declare function sameOriginPath(path: string): string;
3
+ export declare function matchRedirect(redirects: readonly HostRoutingRedirect[], host: string, path: string, search: string): {
4
+ readonly to: string;
5
+ readonly status: 301 | 302;
6
+ } | undefined;
@@ -0,0 +1,27 @@
1
+ import { normalizeHostname } from "./host.js";
2
+ // Same-origin targets collapse leading slashes and backslashes so an appended
3
+ // "//evil.example" remainder can never turn into a protocol-relative URL.
4
+ export function sameOriginPath(path) {
5
+ return `/${path.replace(/^[/\\]+/, "")}`;
6
+ }
7
+ function remainderAfter(from, path) {
8
+ if (path === from)
9
+ return "";
10
+ const prefix = from.endsWith("/") ? from : `${from}/`;
11
+ return path.startsWith(prefix) ? path.slice(prefix.length - 1) : null;
12
+ }
13
+ export function matchRedirect(redirects, host, path, search) {
14
+ const hostname = normalizeHostname(host);
15
+ for (const rule of redirects) {
16
+ if (rule.host !== undefined && rule.host !== hostname)
17
+ continue;
18
+ const remainder = remainderAfter(rule.from, path);
19
+ if (remainder === null)
20
+ continue;
21
+ const target = rule.to.startsWith("https://")
22
+ ? `${rule.to.replace(/\/$/, "")}${remainder}`
23
+ : sameOriginPath(`${rule.to.replace(/\/$/, "")}${remainder}`);
24
+ return { to: `${target}${search}`, status: rule.status ?? 301 };
25
+ }
26
+ return undefined;
27
+ }