@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.
- package/package.json +9 -9
- package/src/auth-email-password/__tests__/auth-tenants-rate-limit.integration.test.ts +3 -0
- package/src/auth-email-password/__tests__/post-auth-landing.integration.test.ts +321 -0
- package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.test.ts +4 -0
- package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +4 -0
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +21 -1
- package/src/auth-email-password/__tests__/signup-handover.integration.test.ts +10 -0
- package/src/auth-email-password/auth-paths.ts +10 -6
- package/src/auth-email-password/changes.json +12 -0
- package/src/auth-email-password/index.ts +1 -1
- package/src/auth-email-password/web/__tests__/auth-form-logic.test.ts +34 -1
- package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +30 -0
- package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +26 -0
- package/src/auth-email-password/web/auth-client.ts +16 -1
- package/src/auth-email-password/web/auth-form-logic.ts +10 -0
- package/src/auth-email-password/web/invite-accept-screen.tsx +10 -5
- package/src/auth-email-password/web/signup-complete-screen.tsx +11 -7
- package/src/auth-mfa/web/mfa-client.ts +18 -2
- package/src/billing-foundation/__tests__/billing-plans.integration.test.ts +27 -0
- package/src/billing-foundation/__tests__/checkout-core.test.ts +35 -0
- package/src/billing-foundation/__tests__/sync-subscription.integration.test.ts +469 -0
- package/src/billing-foundation/changes.json +14 -0
- package/src/billing-foundation/checkout-core.ts +8 -4
- package/src/billing-foundation/constants.ts +6 -0
- package/src/billing-foundation/feature.ts +37 -2
- package/src/billing-foundation/handlers/process-event.write.ts +112 -106
- package/src/billing-foundation/handlers/switch-plan.write.ts +7 -0
- package/src/billing-foundation/handlers/sync-subscription.write.ts +164 -0
- package/src/billing-foundation/i18n.ts +9 -0
- package/src/billing-foundation/index.ts +1 -0
- package/src/billing-foundation/plan-catalog.ts +22 -5
- package/src/billing-foundation/types.ts +27 -0
- package/src/billing-foundation/web/__tests__/billing-plans-panel.test.tsx +28 -0
- package/src/billing-foundation/web/billing-plans-panel.tsx +8 -1
- package/src/channel-email/__tests__/email-channel.test.ts +92 -0
- package/src/channel-email/__tests__/smtp-transport-pinning.test.ts +42 -0
- package/src/channel-email/changes.json +9 -1
- package/src/channel-email/email-channel.ts +31 -6
- package/src/channel-email/smtp-transport.ts +7 -0
- package/src/delivery/__tests__/delivery.integration.test.ts +101 -26
- package/src/delivery/changes.json +7 -0
- package/src/delivery/feature.ts +1 -1
- package/src/delivery/handlers/unsubscribe-address.write.ts +1 -1
- package/src/delivery/handlers/unsubscribe-user.write.ts +1 -1
- package/src/delivery/index.ts +2 -1
- package/src/delivery/public-names.ts +1 -1
- package/src/delivery/unsubscribe.ts +167 -89
- package/src/file-derivatives/feature.ts +1 -1
- package/src/file-derivatives/handlers/public-variant.query.ts +5 -7
- package/src/foundation-shared/__tests__/mail-host-policy.test.ts +68 -0
- package/src/foundation-shared/index.ts +9 -0
- package/src/foundation-shared/mail-host-policy.ts +70 -0
- package/src/inbound-provider-imap/__tests__/imap-foundation.integration.test.ts +11 -0
- package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +14 -1
- package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +88 -3
- package/src/inbound-provider-imap/changes.json +14 -1
- package/src/inbound-provider-imap/feature.ts +6 -3
- package/src/inbound-provider-imap/imap-client.ts +60 -2
- package/src/inbound-provider-imap/index.ts +1 -1
- package/src/ledger/__tests__/ledger.integration.test.ts +80 -0
- package/src/ledger/changes.json +9 -1
- package/src/ledger/entity.ts +14 -1
- package/src/mail-foundation/__tests__/mail-foundation.integration.test.ts +86 -1
- package/src/mail-transport-smtp/__tests__/feature.test.ts +123 -3
- package/src/mail-transport-smtp/changes.json +14 -1
- package/src/mail-transport-smtp/feature.ts +76 -1
- package/src/mail-transport-smtp/index.ts +6 -1
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +59 -0
- package/src/sessions/__tests__/sessions.integration.test.ts +5 -0
- package/src/shared/password-hashing.test.ts +21 -13
- package/src/step-dispatcher/__tests__/feature.boot.test.ts +9 -2
- package/src/step-dispatcher/__tests__/webhook-runner.test.ts +283 -0
- package/src/step-dispatcher/changes.json +21 -1
- package/src/step-dispatcher/feature.ts +63 -15
- package/src/step-dispatcher/index.ts +11 -2
- package/src/step-dispatcher/webhook-runner.ts +157 -31
- package/src/subscription-stripe/__tests__/plugin-methods.test.ts +117 -0
- package/src/subscription-stripe/changes.json +6 -0
- package/src/subscription-stripe/feature.ts +5 -1
- package/src/subscription-stripe/plugin-methods.ts +53 -0
- package/src/subscription-stripe/verify-webhook.ts +52 -28
- package/src/tenant-handover/__tests__/claim.integration.test.ts +62 -4
- package/src/tenant-handover/changes.json +7 -0
- package/src/tenant-handover/handlers/claim.write.ts +1 -0
- package/src/tenant-handover/move-entity-graph.ts +30 -44
- package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +11 -0
- package/src/user-data-rights/__tests__/download-by-token-ip-bucket.integration.test.ts +170 -0
- package/src/user-data-rights/__tests__/download.integration.test.ts +12 -6
- package/src/user-data-rights/__tests__/extract-audit-meta.test.ts +36 -0
- package/src/user-data-rights/changes.json +6 -0
- 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
|
-
* [
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
179
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
222
|
-
|
|
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
|
|
311
|
+
"missing unsubscribe token",
|
|
227
312
|
);
|
|
228
313
|
}
|
|
314
|
+
await verifyUnsubscribeToken(token, encodedSecret);
|
|
315
|
+
return { token };
|
|
229
316
|
},
|
|
230
|
-
handler: async (c,
|
|
231
|
-
|
|
232
|
-
|
|
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
|
|
248
|
-
|
|
249
|
-
|
|
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.
|
|
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\"`)
|
|
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"
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
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 });
|