@cosmicdrift/kumiko-bundled-features 0.319.0 → 0.321.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 (91) hide show
  1. package/package.json +9 -9
  2. package/src/auth-email-password/__tests__/auth-tenants-rate-limit.integration.test.ts +3 -0
  3. package/src/auth-email-password/__tests__/post-auth-landing.integration.test.ts +321 -0
  4. package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.test.ts +4 -0
  5. package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +4 -0
  6. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +21 -1
  7. package/src/auth-email-password/__tests__/signup-handover.integration.test.ts +10 -0
  8. package/src/auth-email-password/auth-paths.ts +10 -6
  9. package/src/auth-email-password/changes.json +12 -0
  10. package/src/auth-email-password/index.ts +1 -1
  11. package/src/auth-email-password/web/__tests__/auth-form-logic.test.ts +34 -1
  12. package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +30 -0
  13. package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +26 -0
  14. package/src/auth-email-password/web/auth-client.ts +16 -1
  15. package/src/auth-email-password/web/auth-form-logic.ts +10 -0
  16. package/src/auth-email-password/web/invite-accept-screen.tsx +10 -5
  17. package/src/auth-email-password/web/signup-complete-screen.tsx +11 -7
  18. package/src/auth-mfa/web/mfa-client.ts +18 -2
  19. package/src/billing-foundation/__tests__/billing-plans.integration.test.ts +27 -0
  20. package/src/billing-foundation/__tests__/checkout-core.test.ts +35 -0
  21. package/src/billing-foundation/__tests__/sync-subscription.integration.test.ts +469 -0
  22. package/src/billing-foundation/changes.json +14 -0
  23. package/src/billing-foundation/checkout-core.ts +8 -4
  24. package/src/billing-foundation/constants.ts +6 -0
  25. package/src/billing-foundation/feature.ts +37 -2
  26. package/src/billing-foundation/handlers/process-event.write.ts +112 -106
  27. package/src/billing-foundation/handlers/switch-plan.write.ts +7 -0
  28. package/src/billing-foundation/handlers/sync-subscription.write.ts +164 -0
  29. package/src/billing-foundation/i18n.ts +9 -0
  30. package/src/billing-foundation/index.ts +1 -0
  31. package/src/billing-foundation/plan-catalog.ts +22 -5
  32. package/src/billing-foundation/types.ts +27 -0
  33. package/src/billing-foundation/web/__tests__/billing-plans-panel.test.tsx +28 -0
  34. package/src/billing-foundation/web/billing-plans-panel.tsx +8 -1
  35. package/src/channel-email/__tests__/email-channel.test.ts +92 -0
  36. package/src/channel-email/__tests__/smtp-transport-pinning.test.ts +42 -0
  37. package/src/channel-email/changes.json +9 -1
  38. package/src/channel-email/email-channel.ts +31 -6
  39. package/src/channel-email/smtp-transport.ts +7 -0
  40. package/src/delivery/__tests__/delivery.integration.test.ts +101 -26
  41. package/src/delivery/changes.json +7 -0
  42. package/src/delivery/feature.ts +1 -1
  43. package/src/delivery/handlers/unsubscribe-address.write.ts +1 -1
  44. package/src/delivery/handlers/unsubscribe-user.write.ts +1 -1
  45. package/src/delivery/index.ts +2 -1
  46. package/src/delivery/public-names.ts +1 -1
  47. package/src/delivery/unsubscribe.ts +167 -89
  48. package/src/file-derivatives/feature.ts +1 -1
  49. package/src/file-derivatives/handlers/public-variant.query.ts +5 -7
  50. package/src/foundation-shared/__tests__/mail-host-policy.test.ts +68 -0
  51. package/src/foundation-shared/index.ts +9 -0
  52. package/src/foundation-shared/mail-host-policy.ts +70 -0
  53. package/src/inbound-provider-imap/__tests__/imap-foundation.integration.test.ts +11 -0
  54. package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +14 -1
  55. package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +88 -3
  56. package/src/inbound-provider-imap/changes.json +14 -1
  57. package/src/inbound-provider-imap/feature.ts +6 -3
  58. package/src/inbound-provider-imap/imap-client.ts +60 -2
  59. package/src/inbound-provider-imap/index.ts +1 -1
  60. package/src/ledger/__tests__/ledger.integration.test.ts +80 -0
  61. package/src/ledger/changes.json +9 -1
  62. package/src/ledger/entity.ts +14 -1
  63. package/src/mail-foundation/__tests__/mail-foundation.integration.test.ts +86 -1
  64. package/src/mail-transport-smtp/__tests__/feature.test.ts +123 -3
  65. package/src/mail-transport-smtp/changes.json +14 -1
  66. package/src/mail-transport-smtp/feature.ts +76 -1
  67. package/src/mail-transport-smtp/index.ts +6 -1
  68. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +59 -0
  69. package/src/sessions/__tests__/sessions.integration.test.ts +5 -0
  70. package/src/shared/password-hashing.test.ts +21 -13
  71. package/src/step-dispatcher/__tests__/feature.boot.test.ts +9 -2
  72. package/src/step-dispatcher/__tests__/webhook-runner.test.ts +283 -0
  73. package/src/step-dispatcher/changes.json +21 -1
  74. package/src/step-dispatcher/feature.ts +63 -15
  75. package/src/step-dispatcher/index.ts +11 -2
  76. package/src/step-dispatcher/webhook-runner.ts +157 -31
  77. package/src/subscription-stripe/__tests__/plugin-methods.test.ts +117 -0
  78. package/src/subscription-stripe/changes.json +6 -0
  79. package/src/subscription-stripe/feature.ts +5 -1
  80. package/src/subscription-stripe/plugin-methods.ts +53 -0
  81. package/src/subscription-stripe/verify-webhook.ts +52 -28
  82. package/src/tenant-handover/__tests__/claim.integration.test.ts +62 -4
  83. package/src/tenant-handover/changes.json +7 -0
  84. package/src/tenant-handover/handlers/claim.write.ts +1 -0
  85. package/src/tenant-handover/move-entity-graph.ts +30 -44
  86. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +11 -0
  87. package/src/user-data-rights/__tests__/download-by-token-ip-bucket.integration.test.ts +170 -0
  88. package/src/user-data-rights/__tests__/download.integration.test.ts +12 -6
  89. package/src/user-data-rights/__tests__/extract-audit-meta.test.ts +36 -0
  90. package/src/user-data-rights/changes.json +6 -0
  91. package/src/user-data-rights/feature.ts +35 -37
@@ -1,9 +1,12 @@
1
1
  import {
2
2
  type ExtraRouteDefinition,
3
3
  ExtraRouteRejection,
4
+ type SignatureExtraRouteDeps,
5
+ type SignatureExtraRouteVerifyRequest,
4
6
  signatureRoute,
5
7
  } from "@cosmicdrift/kumiko-framework/api";
6
8
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
9
+ import { escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
7
10
  import * as jose from "jose";
8
11
  import * as z from "zod";
9
12
  import { hashUnsubscribeAddress } from "./address-opt-out";
@@ -55,8 +58,8 @@ export type AddressUnsubscribeTokenPayload = {
55
58
  /**
56
59
  * `secret` must be a value dedicated to unsubscribe-token signing — do NOT
57
60
  * reuse the app's session `JWT_SECRET`. The app mounting `extraRoutes:
58
- * [createUnsubscribeRoute({ secret })]` signs outgoing links with the same
59
- * value via `signUnsubscribeToken` / `signAddressUnsubscribeToken`.
61
+ * [...createUnsubscribeRoutes({ secret })]` signs outgoing links with the
62
+ * same value via `signUnsubscribeToken` / `signAddressUnsubscribeToken`.
60
63
  */
61
64
  export type UnsubscribeRouteOptions = {
62
65
  readonly secret: string;
@@ -152,110 +155,185 @@ type VerifiedUnsubscribe =
152
155
  readonly channel: string;
153
156
  };
154
157
 
155
- export function createUnsubscribeRoute(options: UnsubscribeRouteOptions): ExtraRouteDefinition {
156
- assertUnsubscribeSecret(options.secret, "createUnsubscribeRoute");
157
- const encodedSecret = new TextEncoder().encode(options.secret);
158
+ // RFC 8058 fixed value — both the List-Unsubscribe-Post header and the body a
159
+ // one-click client POSTs.
160
+ export const DELIVERY_UNSUBSCRIBE_ONE_CLICK_HEADER_VALUE = "List-Unsubscribe=One-Click" as const;
158
161
 
159
- return signatureRoute<VerifiedUnsubscribe>({
160
- method: "GET",
161
- path: DELIVERY_UNSUBSCRIBE_PATH,
162
- entry: "signature",
163
- // Every throw here must become the same ExtraRouteRejection(400,
164
- // unsubscribe_token_invalid) — anything else falls through to the
165
- // framework's generic 401 extra_route_signature_invalid mapping, which
166
- // would leak jose's internal error text into the response body.
167
- verify: async ({ query }) => {
168
- try {
169
- const token = query["token"];
170
- if (!token) {
171
- throw new ExtraRouteRejection(
172
- 400,
173
- UNSUBSCRIBE_TOKEN_INVALID_BODY,
174
- "missing unsubscribe token",
175
- );
176
- }
162
+ const CONFIRMATION_PAGE_HEADERS = {
163
+ "Cache-Control": "no-store",
164
+ "Referrer-Policy": "no-referrer",
165
+ } as const;
177
166
 
178
- const { payload: verifiedPayload } = await jose.jwtVerify(token, encodedSecret, {
179
- issuer: "kumiko:unsubscribe",
180
- });
167
+ // Every throw here must become the same ExtraRouteRejection(400,
168
+ // unsubscribe_token_invalid) — anything else falls through to the
169
+ // framework's generic 401 extra_route_signature_invalid mapping, which
170
+ // would leak jose's internal error text into the response body.
171
+ async function verifyUnsubscribeToken(
172
+ token: string | undefined,
173
+ encodedSecret: Uint8Array,
174
+ ): Promise<VerifiedUnsubscribe> {
175
+ try {
176
+ if (!token) {
177
+ throw new ExtraRouteRejection(
178
+ 400,
179
+ UNSUBSCRIBE_TOKEN_INVALID_BODY,
180
+ "missing unsubscribe token",
181
+ );
182
+ }
181
183
 
182
- const addressAttempt = addressUnsubscribeJwtPayloadSchema.safeParse(verifiedPayload);
183
- if (addressAttempt.success) {
184
- const parsed = addressAttempt.data;
185
- if (parsed.sub !== parsed.addressHash) {
186
- throw new ExtraRouteRejection(
187
- 400,
188
- UNSUBSCRIBE_TOKEN_INVALID_BODY,
189
- "address token subject mismatch",
190
- );
191
- }
192
- // @cast-boundary engine-bridge — string post-zod → branded TenantId
193
- const tenantId = parsed.tenantId as TenantId;
194
- return {
195
- kind: "address",
196
- tenantId,
197
- addressHash: parsed.addressHash,
198
- notificationType: parsed.notificationType,
199
- channel: parsed.channel,
200
- };
201
- }
184
+ const { payload: verifiedPayload } = await jose.jwtVerify(token, encodedSecret, {
185
+ issuer: "kumiko:unsubscribe",
186
+ });
187
+
188
+ const addressAttempt = addressUnsubscribeJwtPayloadSchema.safeParse(verifiedPayload);
189
+ if (addressAttempt.success) {
190
+ const parsed = addressAttempt.data;
191
+ if (parsed.sub !== parsed.addressHash) {
192
+ throw new ExtraRouteRejection(
193
+ 400,
194
+ UNSUBSCRIBE_TOKEN_INVALID_BODY,
195
+ "address token subject mismatch",
196
+ );
197
+ }
198
+ // @cast-boundary engine-bridge — string post-zod → branded TenantId
199
+ const tenantId = parsed.tenantId as TenantId;
200
+ return {
201
+ kind: "address",
202
+ tenantId,
203
+ addressHash: parsed.addressHash,
204
+ notificationType: parsed.notificationType,
205
+ channel: parsed.channel,
206
+ };
207
+ }
208
+
209
+ const userAttempt = unsubscribeJwtPayloadSchema.safeParse(verifiedPayload);
210
+ if (!userAttempt.success) {
211
+ throw new ExtraRouteRejection(
212
+ 400,
213
+ UNSUBSCRIBE_TOKEN_INVALID_BODY,
214
+ "unsubscribe token payload matched neither schema",
215
+ );
216
+ }
217
+ const parsed = userAttempt.data;
218
+ // @cast-boundary engine-bridge — string post-zod → branded TenantId
219
+ const tenantId = parsed.tenantId as TenantId;
220
+ return {
221
+ kind: "user",
222
+ tenantId,
223
+ userId: parsed.sub,
224
+ notificationType: parsed.notificationType,
225
+ channel: parsed.channel,
226
+ };
227
+ } catch (err) {
228
+ if (err instanceof ExtraRouteRejection) throw err;
229
+ throw new ExtraRouteRejection(
230
+ 400,
231
+ UNSUBSCRIBE_TOKEN_INVALID_BODY,
232
+ "unsubscribe token verification failed",
233
+ );
234
+ }
235
+ }
202
236
 
203
- const userAttempt = unsubscribeJwtPayloadSchema.safeParse(verifiedPayload);
204
- if (!userAttempt.success) {
205
- throw new ExtraRouteRejection(
206
- 400,
207
- UNSUBSCRIBE_TOKEN_INVALID_BODY,
208
- "unsubscribe token payload matched neither schema",
209
- );
237
+ async function dispatchUnsubscribeWrite(
238
+ verified: VerifiedUnsubscribe,
239
+ deps: SignatureExtraRouteDeps,
240
+ ): Promise<boolean> {
241
+ const payload =
242
+ verified.kind === "address"
243
+ ? {
244
+ addressHash: verified.addressHash,
245
+ notificationType: verified.notificationType,
246
+ channel: verified.channel,
210
247
  }
211
- const parsed = userAttempt.data;
212
- // @cast-boundary engine-bridge — string post-zod → branded TenantId
213
- const tenantId = parsed.tenantId as TenantId;
214
- return {
215
- kind: "user",
216
- tenantId,
217
- userId: parsed.sub,
218
- notificationType: parsed.notificationType,
219
- channel: parsed.channel,
248
+ : {
249
+ userId: verified.userId,
250
+ notificationType: verified.notificationType,
251
+ channel: verified.channel,
220
252
  };
221
- } catch (err) {
222
- if (err instanceof ExtraRouteRejection) throw err;
253
+
254
+ const dispatched = await deps.dispatchSystemWrite({
255
+ handlerQn:
256
+ verified.kind === "address"
257
+ ? DeliveryHandlers.unsubscribeAddress
258
+ : DeliveryHandlers.unsubscribeUser,
259
+ tenantId: verified.tenantId,
260
+ payload,
261
+ });
262
+ return dispatched.isSuccess;
263
+ }
264
+
265
+ function confirmationPage(token: string): string {
266
+ return `<!doctype html>
267
+ <html>
268
+ <head>
269
+ <meta charset="utf-8">
270
+ <meta name="robots" content="noindex">
271
+ <title>Unsubscribe</title>
272
+ </head>
273
+ <body>
274
+ <p>Unsubscribe from these emails?</p>
275
+ <form method="post" action="${DELIVERY_UNSUBSCRIBE_PATH}">
276
+ <input type="hidden" name="token" value="${escapeHtmlAttr(token)}">
277
+ <button type="submit">Unsubscribe</button>
278
+ </form>
279
+ </body>
280
+ </html>`;
281
+ }
282
+
283
+ // One-click clients put the token in the query string; only the
284
+ // confirmation-page form submits it in the body.
285
+ function tokenFromPostRequest(request: SignatureExtraRouteVerifyRequest): string | undefined {
286
+ const contentType = request.headers["content-type"];
287
+ if (contentType?.includes("application/x-www-form-urlencoded")) {
288
+ const fromBody = new URLSearchParams(request.rawBody).get("token");
289
+ if (fromBody) return fromBody;
290
+ }
291
+ return request.query["token"];
292
+ }
293
+
294
+ // GET renders a confirmation page without writing; POST performs the opt-out.
295
+ export function createUnsubscribeRoutes(
296
+ options: UnsubscribeRouteOptions,
297
+ ): readonly ExtraRouteDefinition[] {
298
+ assertUnsubscribeSecret(options.secret, "createUnsubscribeRoutes");
299
+ const encodedSecret = new TextEncoder().encode(options.secret);
300
+
301
+ const confirmRoute = signatureRoute<{ token: string }>({
302
+ method: "GET",
303
+ path: DELIVERY_UNSUBSCRIBE_PATH,
304
+ entry: "signature",
305
+ verify: async ({ query }) => {
306
+ const token = query["token"];
307
+ if (!token) {
223
308
  throw new ExtraRouteRejection(
224
309
  400,
225
310
  UNSUBSCRIBE_TOKEN_INVALID_BODY,
226
- "unsubscribe token verification failed",
311
+ "missing unsubscribe token",
227
312
  );
228
313
  }
314
+ await verifyUnsubscribeToken(token, encodedSecret);
315
+ return { token };
229
316
  },
230
- handler: async (c, verified, deps) => {
231
- const payload =
232
- verified.kind === "address"
233
- ? {
234
- addressHash: verified.addressHash,
235
- notificationType: verified.notificationType,
236
- channel: verified.channel,
237
- }
238
- : {
239
- userId: verified.userId,
240
- notificationType: verified.notificationType,
241
- channel: verified.channel,
242
- };
317
+ handler: async (c, { token }) =>
318
+ c.html(confirmationPage(token), 200, CONFIRMATION_PAGE_HEADERS),
319
+ });
243
320
 
321
+ const writeRoute = signatureRoute<VerifiedUnsubscribe>({
322
+ method: "POST",
323
+ path: DELIVERY_UNSUBSCRIBE_PATH,
324
+ entry: "signature",
325
+ verify: async (request) => verifyUnsubscribeToken(tokenFromPostRequest(request), encodedSecret),
326
+ handler: async (c, verified, deps) => {
244
327
  // Token-verify passed — everything below is a legitimate write. Don't
245
328
  // swallow write-errors as "invalid token", that would mask real bugs
246
329
  // (e.g. events-table missing, DB down) behind a misleading 400.
247
- const dispatched = await deps.dispatchSystemWrite({
248
- handlerQn:
249
- verified.kind === "address"
250
- ? DeliveryHandlers.unsubscribeAddress
251
- : DeliveryHandlers.unsubscribeUser,
252
- tenantId: verified.tenantId,
253
- payload,
254
- });
255
- if (!dispatched.isSuccess) {
256
- return c.text("Unsubscribe failed", 500);
330
+ const succeeded = await dispatchUnsubscribeWrite(verified, deps);
331
+ if (!succeeded) {
332
+ return c.html("Unsubscribe failed", 500, { "Cache-Control": "no-store" });
257
333
  }
258
- return c.text("You have been unsubscribed.", 200);
334
+ return c.html("You have been unsubscribed.", 200, { "Cache-Control": "no-store" });
259
335
  },
260
336
  });
337
+
338
+ return [confirmRoute, writeRoute];
261
339
  }
@@ -102,7 +102,7 @@ export function createFileDerivativesFeature(opts: FileDerivativesOptions = {}):
102
102
 
103
103
  return defineFeature(FEATURE_NAME, (r) => {
104
104
  r.describe(
105
- "Declares the `derivativeRenderer` extension point. `ctx.derivatives.variant(fileRefId, spec, name)` derives a variant of a tracked FileRef the first time it's requested and reuses the stored result afterwards (derive-on-first-use, keyed by a hash of the spec). Mount at least one `derivatives-*` renderer feature alongside this one — without a registered renderer for the FileRef's MIME type, every `variant(...)` call throws. Also declares the `derivativePublicPredicate` extension point (`r.useExtension(EXT_DERIVATIVE_PUBLIC_PREDICATE, '<entityType>', { isPublic })`) and, when `createFileDerivativesFeature({resolveApexTenant})` is passed a host-resolver, mounts an anonymous `GET {basePath}/:fileRefId/:variant` route that serves any variant name the FileRef's field declared in its `variants` for a FileRef whose entityType has a registered predicate returning true — default-deny (404) otherwise, same as an unknown FileRef or an undeclared variant name. The route's only rate-limit (`per: \"ip\"`) trusts the first `x-forwarded-for` hop — deployers must ensure their ingress overwrites rather than appends to that header, or the throttle is bypassable by rotating it. `publicTenantResolution: \"fileRef\"` keeps resolveApexTenant as the host gate but reads the variant from the FileRef row's own tenant instead of the host's, so one shared platform host serves every tenant's public variants — each FileRef-tenant's own `isPublic` predicate still default-denies. Also declares the `derivativeOverlayResolver` extension point (`r.useExtension(EXT_DERIVATIVE_OVERLAY_RESOLVER, '<entityType>', { resolve })`), used to turn a variant's `overlays[].dataToken` into the actual QR payload for that FileRef's entityType before the variant is rendered — a variant declaring a `qr` overlay throws at request-time if no resolver is registered for the FileRef's entityType.",
105
+ "Declares the `derivativeRenderer` extension point. `ctx.derivatives.variant(fileRefId, spec, name)` derives a variant of a tracked FileRef the first time it's requested and reuses the stored result afterwards (derive-on-first-use, keyed by a hash of the spec). Mount at least one `derivatives-*` renderer feature alongside this one — without a registered renderer for the FileRef's MIME type, every `variant(...)` call throws. Also declares the `derivativePublicPredicate` extension point (`r.useExtension(EXT_DERIVATIVE_PUBLIC_PREDICATE, '<entityType>', { isPublic })`) and, when `createFileDerivativesFeature({resolveApexTenant})` is passed a host-resolver, mounts an anonymous `GET {basePath}/:fileRefId/:variant` route that serves any variant name the FileRef's field declared in its `variants` for a FileRef whose entityType has a registered predicate returning true — default-deny (404) otherwise, same as an unknown FileRef or an undeclared variant name. The route's only rate-limit (`per: \"ip\"`) depends on the server's `trustedProxyHops` matching the deployment's actual reverse-proxy count — at the default 0 it ignores `x-forwarded-for` entirely, so every caller shares one bucket until that's configured. `publicTenantResolution: \"fileRef\"` keeps resolveApexTenant as the host gate but reads the variant from the FileRef row's own tenant instead of the host's, so one shared platform host serves every tenant's public variants — each FileRef-tenant's own `isPublic` predicate still default-denies. Also declares the `derivativeOverlayResolver` extension point (`r.useExtension(EXT_DERIVATIVE_OVERLAY_RESOLVER, '<entityType>', { resolve })`), used to turn a variant's `overlays[].dataToken` into the actual QR payload for that FileRef's entityType before the variant is rendered — a variant declaring a `qr` overlay throws at request-time if no resolver is registered for the FileRef's entityType.",
106
106
  );
107
107
  r.uiHints({
108
108
  displayLabel: "File Derivatives",
@@ -92,13 +92,11 @@ export const publicVariantQuery = defineQueryHandler({
92
92
  schema: publicVariantPayloadSchema,
93
93
  access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
94
94
  agent: { expose: false },
95
- // ponytail: "ip" trusts the first x-forwarded-for hop (buildRequestContextData
96
- // in request-id-middleware.ts) — this is the ONLY throttle on an anonymous,
97
- // internet-facing render/storage-cost route, so it assumes the deployment's
98
- // ingress overwrites (not appends to) x-forwarded-for. A direct-to-origin
99
- // deployment lets a caller rotate the header per request and bypass this.
100
- // Upgrade path if that assumption doesn't hold: trusted-proxy-count config
101
- // on buildRequestContextData, same fix point as every other /api/* route.
95
+ // ponytail: "ip" is the ONLY throttle on an anonymous, internet-facing
96
+ // render/storage-cost route — its accuracy depends on the server's
97
+ // `trustedProxyHops` matching the real number of reverse proxies in
98
+ // front of it (see api/client-ip.ts). Left at the default 0 with a proxy
99
+ // actually in front, every caller collapses into one shared bucket.
102
100
  rateLimit: { per: "ip", limit: 60, windowSeconds: 60 },
103
101
  handler: async (query, ctx) => {
104
102
  const row = await fetchOne<FileRefRow>(ctx.db, fileRefsTable, {
@@ -0,0 +1,68 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { lookup } from "node:dns/promises";
3
+ import {
4
+ BlockedHostError,
5
+ HostResolutionError,
6
+ resolveMailConnectTarget,
7
+ } from "../mail-host-policy";
8
+
9
+ describe("resolveMailConnectTarget", () => {
10
+ test("pins a public hostname to its resolved address and sets servername to the original host", async () => {
11
+ const fakeLookup = (async () => [
12
+ { address: "203.0.113.5", family: 4 },
13
+ ]) as unknown as typeof lookup;
14
+
15
+ await expect(
16
+ resolveMailConnectTarget("smtp.tenant.example", { lookupFn: fakeLookup }),
17
+ ).resolves.toEqual({ host: "203.0.113.5", servername: "smtp.tenant.example" });
18
+ });
19
+
20
+ test("connects a public IP-literal host directly, without a servername", async () => {
21
+ await expect(resolveMailConnectTarget("93.184.216.34")).resolves.toEqual({
22
+ host: "93.184.216.34",
23
+ });
24
+ });
25
+
26
+ test("rejects a private IP-literal host", async () => {
27
+ await expect(resolveMailConnectTarget("10.0.0.5")).rejects.toThrow(BlockedHostError);
28
+ });
29
+
30
+ test("rejects a hostname that resolves to a private address", async () => {
31
+ const fakeLookup = (async () => [
32
+ { address: "127.0.0.1", family: 4 },
33
+ ]) as unknown as typeof lookup;
34
+
35
+ await expect(
36
+ resolveMailConnectTarget("internal.example", { lookupFn: fakeLookup }),
37
+ ).rejects.toThrow(BlockedHostError);
38
+ });
39
+
40
+ test("surfaces a DNS failure distinctly from a blocked host", async () => {
41
+ const failingLookup = (async () => {
42
+ throw new Error("ENOTFOUND");
43
+ }) as unknown as typeof lookup;
44
+
45
+ await expect(
46
+ resolveMailConnectTarget("nowhere.example", { lookupFn: failingLookup }),
47
+ ).rejects.toThrow(HostResolutionError);
48
+ });
49
+
50
+ test("allowlisted private host bypasses resolution entirely, case-insensitively", async () => {
51
+ const lookupFn = (() => {
52
+ throw new Error("must not be called for an allowlisted host");
53
+ }) as unknown as typeof lookup;
54
+
55
+ await expect(
56
+ resolveMailConnectTarget("Mailpit.internal", {
57
+ allowedPrivateMailHosts: ["mailpit.internal"],
58
+ lookupFn,
59
+ }),
60
+ ).resolves.toEqual({ host: "Mailpit.internal" });
61
+ });
62
+
63
+ test("a host absent from the allowlist still goes through resolution", async () => {
64
+ await expect(
65
+ resolveMailConnectTarget("10.0.0.5", { allowedPrivateMailHosts: ["mailpit.internal"] }),
66
+ ).rejects.toThrow(BlockedHostError);
67
+ });
68
+ });
@@ -2,3 +2,12 @@
2
2
  // Foundation packages (ai-foundation, mail-foundation, file-foundation).
3
3
 
4
4
  export { requireDefined, requireNonEmpty, requireSecretSet } from "./config-helpers";
5
+ export {
6
+ BlockedHostError,
7
+ HostResolutionError,
8
+ MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR,
9
+ type MailConnectTarget,
10
+ type MailHostGuardOptions,
11
+ readAllowedPrivateMailHostsFromEnv,
12
+ resolveMailConnectTarget,
13
+ } from "./mail-host-policy";
@@ -0,0 +1,70 @@
1
+ // Connect-time egress guard for tenant-supplied SMTP/IMAP hosts (shared by
2
+ // mail-transport-smtp and inbound-provider-imap): a tenant-controlled host
3
+ // must resolve to a public address, never an internal one. Resolution
4
+ // happens exactly once and the caller connects to the resolved address
5
+ // directly (see kumiko-http's resolvePublicHostname for why that matters),
6
+ // so this also covers hosts that were valid when a tenant set them and
7
+ // only later re-resolve to a private address.
8
+ //
9
+ // `allowedPrivateMailHosts` is the operator's own escape hatch for an
10
+ // internal relay or a dev/test server (mailpit, greenmail) — deliberately
11
+ // an operator env var (KUMIKO_MAIL_ALLOWED_PRIVATE_HOSTS, read via
12
+ // readAllowedPrivateMailHostsFromEnv), never a tenant-config key, so a
13
+ // tenant can never grant themselves the bypass. Shared by both features
14
+ // since a deploy commonly mounts both; declared once in mail-transport-
15
+ // smtp's envSchema (see its feature.ts) to avoid the framework's env-var-
16
+ // conflict check tripping when both features are mounted together.
17
+
18
+ import type { lookup } from "node:dns/promises";
19
+ import { isIP } from "node:net";
20
+ import { resolvePublicHostname } from "@cosmicdrift/kumiko-framework/http";
21
+
22
+ export const MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR = "KUMIKO_MAIL_ALLOWED_PRIVATE_HOSTS";
23
+
24
+ /** Parses the comma-separated operator allowlist env var. Never throws —
25
+ * an unset or empty value just means no bypass. */
26
+ export function readAllowedPrivateMailHostsFromEnv(
27
+ env: Readonly<Record<string, string | undefined>> = process.env,
28
+ ): readonly string[] {
29
+ const raw = env[MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR];
30
+ if (!raw) return [];
31
+ return raw
32
+ .split(",")
33
+ .map((host) => host.trim())
34
+ .filter((host) => host.length > 0);
35
+ }
36
+
37
+ export type MailHostGuardOptions = {
38
+ readonly allowedPrivateMailHosts?: readonly string[];
39
+ readonly lookupFn?: typeof lookup;
40
+ };
41
+
42
+ export type MailConnectTarget = {
43
+ readonly host: string;
44
+ readonly servername?: string;
45
+ };
46
+
47
+ function isAllowedPrivateMailHost(host: string, allowed: readonly string[]): boolean {
48
+ return allowed.some((candidate) => candidate.toLowerCase() === host.toLowerCase());
49
+ }
50
+
51
+ export async function resolveMailConnectTarget(
52
+ host: string,
53
+ options: MailHostGuardOptions = {},
54
+ ): Promise<MailConnectTarget> {
55
+ if (isAllowedPrivateMailHost(host, options.allowedPrivateMailHosts ?? [])) {
56
+ return { host };
57
+ }
58
+ const resolved = await resolvePublicHostname(host, options.lookupFn);
59
+ // An IP-literal host pins to itself — no hostname was ever there for TLS
60
+ // SNI/cert validation to check, so `servername` stays unset (the mail
61
+ // libraries themselves skip SNI for an IP host).
62
+ if (isIP(host) !== 0) {
63
+ return { host: resolved.address };
64
+ }
65
+ return { host: resolved.address, servername: host };
66
+ }
67
+
68
+ // Re-exported so callers can classify a rejection (policy verdict, no
69
+ // retry) without importing kumiko-http directly.
70
+ export { BlockedHostError, HostResolutionError } from "@cosmicdrift/kumiko-framework/http";
@@ -40,6 +40,7 @@ import {
40
40
  tenantComplianceProfileEntity,
41
41
  } from "../../compliance-profiles";
42
42
  import { createConfigFeature } from "../../config";
43
+ import { MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR } from "../../foundation-shared";
43
44
  import {
44
45
  createInboundMailSupervisor,
45
46
  InboundMailAccountStatuses,
@@ -63,6 +64,11 @@ const USER = "testuser";
63
64
  const PASSWORD = "testpass";
64
65
  const ADDRESS = "testuser@example.com";
65
66
 
67
+ // greenmail is a local test server, not tenant-supplied — needs the
68
+ // operator escape hatch since HOST is a private/loopback address.
69
+ const originalAllowedPrivateHostsEnv = process.env[MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR];
70
+ process.env[MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR] = HOST;
71
+
66
72
  function probe(host: string, port: number): Promise<boolean> {
67
73
  return new Promise((resolve) => {
68
74
  const socket = connect({ host, port, timeout: 1500 });
@@ -127,6 +133,11 @@ beforeAll(async () => {
127
133
  });
128
134
 
129
135
  afterAll(async () => {
136
+ if (originalAllowedPrivateHostsEnv === undefined) {
137
+ delete process.env[MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR];
138
+ } else {
139
+ process.env[MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR] = originalAllowedPrivateHostsEnv;
140
+ }
130
141
  if (!available) return;
131
142
  await stack.cleanup();
132
143
  resetPiiSubjectKmsForTests();
@@ -15,10 +15,11 @@
15
15
  // — der volle Dispatcher-Pfad ist in
16
16
  // inbound-mail-foundation.integration.test.ts abgedeckt.
17
17
 
18
- import { describe, expect, test } from "bun:test";
18
+ import { afterAll, describe, expect, test } from "bun:test";
19
19
  import { connect } from "node:net";
20
20
  import { createSecret } from "@cosmicdrift/kumiko-framework/secrets";
21
21
  import { createTransport } from "nodemailer";
22
+ import { MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR } from "../../foundation-shared";
22
23
  import {
23
24
  type InboundMailContext,
24
25
  isInboundAuthError,
@@ -35,6 +36,18 @@ const USER = "testuser";
35
36
  const PASSWORD = "testpass";
36
37
  const ADDRESS = "testuser@example.com";
37
38
 
39
+ // greenmail is a local test server, not tenant-supplied — needs the
40
+ // operator escape hatch since HOST is a private/loopback address.
41
+ const originalAllowedPrivateHostsEnv = process.env[MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR];
42
+ process.env[MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR] = HOST;
43
+ afterAll(() => {
44
+ if (originalAllowedPrivateHostsEnv === undefined) {
45
+ delete process.env[MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR];
46
+ } else {
47
+ process.env[MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR] = originalAllowedPrivateHostsEnv;
48
+ }
49
+ });
50
+
38
51
  function probe(host: string, port: number): Promise<boolean> {
39
52
  return new Promise((resolve) => {
40
53
  const socket = connect({ host, port, timeout: 1500 });