@cosmicdrift/kumiko-bundled-features 0.319.0 → 0.320.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 (55) 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/channel-email/__tests__/smtp-transport-pinning.test.ts +42 -0
  20. package/src/channel-email/smtp-transport.ts +7 -0
  21. package/src/file-derivatives/feature.ts +1 -1
  22. package/src/file-derivatives/handlers/public-variant.query.ts +5 -7
  23. package/src/foundation-shared/__tests__/mail-host-policy.test.ts +68 -0
  24. package/src/foundation-shared/index.ts +9 -0
  25. package/src/foundation-shared/mail-host-policy.ts +70 -0
  26. package/src/inbound-provider-imap/__tests__/imap-foundation.integration.test.ts +11 -0
  27. package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +14 -1
  28. package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +88 -3
  29. package/src/inbound-provider-imap/changes.json +14 -1
  30. package/src/inbound-provider-imap/feature.ts +6 -3
  31. package/src/inbound-provider-imap/imap-client.ts +60 -2
  32. package/src/inbound-provider-imap/index.ts +1 -1
  33. package/src/ledger/__tests__/ledger.integration.test.ts +80 -0
  34. package/src/ledger/changes.json +9 -1
  35. package/src/ledger/entity.ts +14 -1
  36. package/src/mail-foundation/__tests__/mail-foundation.integration.test.ts +86 -1
  37. package/src/mail-transport-smtp/__tests__/feature.test.ts +123 -3
  38. package/src/mail-transport-smtp/changes.json +14 -1
  39. package/src/mail-transport-smtp/feature.ts +76 -1
  40. package/src/mail-transport-smtp/index.ts +6 -1
  41. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +59 -0
  42. package/src/sessions/__tests__/sessions.integration.test.ts +5 -0
  43. package/src/shared/password-hashing.test.ts +21 -13
  44. package/src/step-dispatcher/__tests__/webhook-runner.test.ts +131 -0
  45. package/src/step-dispatcher/changes.json +14 -1
  46. package/src/step-dispatcher/feature.ts +16 -1
  47. package/src/step-dispatcher/index.ts +8 -1
  48. package/src/step-dispatcher/webhook-runner.ts +104 -18
  49. package/src/subscription-stripe/changes.json +6 -0
  50. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +11 -0
  51. package/src/user-data-rights/__tests__/download-by-token-ip-bucket.integration.test.ts +170 -0
  52. package/src/user-data-rights/__tests__/download.integration.test.ts +12 -6
  53. package/src/user-data-rights/__tests__/extract-audit-meta.test.ts +36 -0
  54. package/src/user-data-rights/changes.json +6 -0
  55. package/src/user-data-rights/feature.ts +35 -37
@@ -0,0 +1,170 @@
1
+ // #1307 regression: the `/user-export/by-token` httpRoute wrapper re-dispatches
2
+ // to `download-by-token` (rateLimit: { per: "ip", limit: 30 }) via an internal
3
+ // `app.fetch(new Request(...))` call. That internal request has no socket of
4
+ // its own, so the L3 per-ip bucket only sees a real client IP if the wrapper
5
+ // hands it one explicitly. Before the fix it forwarded a synthetic single-hop
6
+ // X-Forwarded-For header — which the shared client-ip resolver only honours
7
+ // at exactly `trustedProxyHops: 1` (see client-ip.ts). At the framework
8
+ // default (0) the header is ignored outright, and at >=2 a single-entry XFF
9
+ // is always short, so both cases fell back to the shared "unknown" bucket:
10
+ // one caller could exhaust the 30/min cap for every anonymous downloader.
11
+ // The fix passes the already-resolved `clientIp` straight through as the
12
+ // internal request's env (2nd `app.fetch` arg), independent of hop count.
13
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
14
+ import { randomBytes } from "node:crypto";
15
+ import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
16
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
17
+ import {
18
+ setupTestStack,
19
+ type TestStack,
20
+ testTenantId,
21
+ unsafeCreateEntityTable,
22
+ unsafePushTables,
23
+ } from "@cosmicdrift/kumiko-framework/stack";
24
+ import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
25
+ import {
26
+ createComplianceProfilesFeature,
27
+ tenantComplianceProfileEntity,
28
+ } from "../../compliance-profiles";
29
+ import { createConfigFeature } from "../../config";
30
+ import { createConfigAccessorFactory } from "../../config/feature";
31
+ import { createConfigResolver } from "../../config/resolver";
32
+ import { configValuesTable } from "../../config/table";
33
+ import { createDataRetentionFeature } from "../../data-retention";
34
+ import { fileFoundationFeature } from "../../file-foundation";
35
+ import { fileProviderInMemoryFeature } from "../../file-provider-inmemory";
36
+ import { createSessionsFeature } from "../../sessions";
37
+ import { createUserFeature } from "../../user";
38
+ import { createUserDataRightsFeature } from "../feature";
39
+ import { exportDownloadTokenEntity } from "../schema/download-token";
40
+ import { exportJobEntity } from "../schema/export-job";
41
+
42
+ const tenant = testTenantId(1);
43
+
44
+ async function buildStack(trustedProxyHops: number): Promise<TestStack> {
45
+ const encryption = createTestEnvelopeCipher(randomBytes(32).toString("base64"));
46
+ const resolver = createConfigResolver({ cipher: encryption });
47
+
48
+ const stack = await setupTestStack({
49
+ features: [
50
+ createConfigFeature(),
51
+ createUserFeature(),
52
+ createDataRetentionFeature(),
53
+ createComplianceProfilesFeature(),
54
+ fileFoundationFeature,
55
+ fileProviderInMemoryFeature,
56
+ authFoundationFeature,
57
+ createSessionsFeature(),
58
+ createUserDataRightsFeature(),
59
+ ],
60
+ extraContext: ({ registry }) => ({
61
+ configResolver: resolver,
62
+ configEncryption: encryption,
63
+ _configAccessorFactory: createConfigAccessorFactory(registry, resolver),
64
+ }),
65
+ anonymousAccess: { defaultTenantId: tenant },
66
+ trustedProxyHops,
67
+ });
68
+ await unsafeCreateEntityTable(stack.db, exportJobEntity);
69
+ await unsafeCreateEntityTable(stack.db, exportDownloadTokenEntity);
70
+ await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
71
+ await unsafePushTables(stack.db, { configValuesTable });
72
+ await asRawClient(stack.db).unsafe(`
73
+ CREATE TABLE IF NOT EXISTS read_tenant_memberships (
74
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
75
+ tenant_id UUID NOT NULL,
76
+ user_id TEXT NOT NULL,
77
+ version INTEGER NOT NULL DEFAULT 0,
78
+ inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
79
+ modified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
80
+ inserted_by_id TEXT,
81
+ modified_by_id TEXT,
82
+ is_deleted BOOLEAN NOT NULL DEFAULT false,
83
+ deleted_at TIMESTAMPTZ,
84
+ deleted_by_id TEXT,
85
+ roles TEXT NOT NULL DEFAULT '[]',
86
+ UNIQUE(user_id, tenant_id)
87
+ )
88
+ `);
89
+ return stack;
90
+ }
91
+
92
+ // Rate-limit runs before the handler body (dispatch-query.ts: feature-gate →
93
+ // rate-limit → access → validation → handler), so a garbage token still
94
+ // consumes the bucket and lets the test avoid seeding a real export job —
95
+ // each call 404s (download.notFound) until the bucket is exhausted, then 429.
96
+ async function postByToken(
97
+ stack: TestStack,
98
+ headers: Record<string, string>,
99
+ socketAddress?: string,
100
+ ): Promise<Response> {
101
+ const request = new Request("http://test/user-export/by-token", {
102
+ method: "POST",
103
+ headers: { "content-type": "application/json", ...headers },
104
+ body: JSON.stringify({ token: "not-a-real-token" }),
105
+ });
106
+ return socketAddress ? stack.app.fetch(request, socketAddress) : stack.app.fetch(request);
107
+ }
108
+
109
+ async function exhaustBucket(
110
+ stack: TestStack,
111
+ headers: Record<string, string>,
112
+ socketAddress?: string,
113
+ ): Promise<void> {
114
+ for (let i = 0; i < 30; i++) {
115
+ const res = await postByToken(stack, headers, socketAddress);
116
+ expect(res.status).toBe(404);
117
+ }
118
+ }
119
+
120
+ describe("download-by-token IP bucket — trustedProxyHops: 0 (socket-only)", () => {
121
+ let stack: TestStack;
122
+
123
+ beforeAll(async () => {
124
+ stack = await buildStack(0);
125
+ });
126
+
127
+ afterAll(async () => {
128
+ await stack.cleanup();
129
+ });
130
+
131
+ test("two distinct callers each get their own 30/min bucket, not a shared one", async () => {
132
+ // hops=0 ignores X-Forwarded-For outright — distinct callers are
133
+ // simulated via distinct socket addresses (2nd app.fetch arg) on the
134
+ // OUTER request; a spoofed XFF must have no effect at all.
135
+ const spoofedHeaders = { "x-forwarded-for": "ignored-at-hops-0" };
136
+ await exhaustBucket(stack, spoofedHeaders, "203.0.113.10");
137
+ const blocked = await postByToken(stack, spoofedHeaders, "203.0.113.10");
138
+ expect(blocked.status).toBe(429);
139
+
140
+ // A second caller (different socket) must not inherit caller A's
141
+ // exhausted bucket, even though it sends the exact same forged XFF.
142
+ const first = await postByToken(stack, spoofedHeaders, "203.0.113.20");
143
+ expect(first.status).toBe(404);
144
+ });
145
+ });
146
+
147
+ describe("download-by-token IP bucket — trustedProxyHops: 2", () => {
148
+ let stack: TestStack;
149
+
150
+ beforeAll(async () => {
151
+ stack = await buildStack(2);
152
+ });
153
+
154
+ afterAll(async () => {
155
+ await stack.cleanup();
156
+ });
157
+
158
+ test("two distinct callers (resolved via a 2-hop XFF chain) each get their own bucket", async () => {
159
+ // entries[length-hops] is the real client: index 1 of a 3-entry chain.
160
+ const callerA = { "x-forwarded-for": "client-forged, 10.1.1.1, 10.0.0.9" };
161
+ const callerB = { "x-forwarded-for": "client-forged, 10.2.2.2, 10.0.0.9" };
162
+
163
+ await exhaustBucket(stack, callerA);
164
+ const blocked = await postByToken(stack, callerA);
165
+ expect(blocked.status).toBe(429);
166
+
167
+ const first = await postByToken(stack, callerB);
168
+ expect(first.status).toBe(404);
169
+ });
170
+ });
@@ -134,6 +134,9 @@ beforeAll(async () => {
134
134
  anonymousAccess: {
135
135
  defaultTenantId: tenantA,
136
136
  },
137
+ // "download-by-job" below asserts the literal ip stored by requestContext,
138
+ // so the resolver needs a trusted hop to read x-forwarded-for at all.
139
+ trustedProxyHops: 1,
137
140
  });
138
141
  await unsafeCreateEntityTable(stack.db, exportJobEntity);
139
142
  await unsafeCreateEntityTable(stack.db, exportDownloadTokenEntity);
@@ -383,10 +386,10 @@ describe("download-by-job :: happy path", () => {
383
386
  test("session-auth: Job-Owner → returns signed URL + audit (IP aus X-Forwarded-For)", async () => {
384
387
  const { jobId } = await seedDoneJobWithToken();
385
388
 
386
- // Der UI-Klick laeuft als direkter download-by-job-Query (Client traegt
387
- // X-CSRF-Token). Die Audit-IP kommt server-trusted aus dem RequestContext
388
- // (X-Forwarded-For, erster Hop), nicht aus einem vom Client mitgeschickten
389
- // Feld.
389
+ // The UI click runs as a direct download-by-job query (client sends
390
+ // X-CSRF-Token). The audit IP is server-trusted from the RequestContext
391
+ // (X-Forwarded-For entry appended by the trusted proxy at
392
+ // trustedProxyHops=1), never from a client-supplied field.
390
393
  const res = await stack.http.queryWithHeaders(
391
394
  "user-data-rights:query:download-by-job",
392
395
  { jobId },
@@ -403,7 +406,7 @@ describe("download-by-job :: happy path", () => {
403
406
  lastUsedFromIp: string | null;
404
407
  }>;
405
408
  expect(row?.useCount).toBe(1);
406
- expect(row?.lastUsedFromIp).toBe("10.0.0.5");
409
+ expect(row?.lastUsedFromIp).toBe("10.0.0.1");
407
410
  });
408
411
 
409
412
  test("failed Job (status != done) → 404 download.unavailable (job-Pfad)", async () => {
@@ -485,6 +488,8 @@ describe("r.httpRoute :: /user-export/by-token (Magic-Link e2e)", () => {
485
488
  test("POST-Exchange (Fragment-Pfad): happy-path + IP/UA-Audit aus httpRoute-Headers", async () => {
486
489
  const { jobId, plainToken } = await seedDoneJobWithToken();
487
490
 
491
+ // trustedProxyHops=1 here → the resolver trusts the last XFF entry, not
492
+ // the client-spoofable first one.
488
493
  const res = await stack.app.fetch(
489
494
  new Request("http://test/user-export/by-token", {
490
495
  method: "POST",
@@ -506,7 +511,8 @@ describe("r.httpRoute :: /user-export/by-token (Magic-Link e2e)", () => {
506
511
  lastUsedUserAgent: string | null;
507
512
  }>;
508
513
  expect(row?.useCount).toBe(1);
509
- expect(row?.lastUsedFromIp).toBe("203.0.113.9");
514
+ expect(row?.lastUsedFromIp).toBe("10.0.0.1");
515
+ expect(row?.lastUsedFromIp).not.toBe("203.0.113.9");
510
516
  expect(row?.lastUsedUserAgent).toBe("e2e-test/3.0");
511
517
  });
512
518
 
@@ -0,0 +1,36 @@
1
+ // extractAuditMeta takes the framework-resolved clientIp instead of reading
2
+ // X-Forwarded-For itself, so a caller can't plant a fake audit IP.
3
+ import { describe, expect, test } from "bun:test";
4
+ import { UNKNOWN_CLIENT_IP } from "@cosmicdrift/kumiko-framework/api";
5
+ import { extractAuditMeta } from "../feature";
6
+
7
+ describe("extractAuditMeta", () => {
8
+ test("uses the resolved clientIp, ignoring a spoofed X-Forwarded-For header on the same request", () => {
9
+ const headers = new Headers({
10
+ "x-forwarded-for": "203.0.113.1, 10.0.0.1",
11
+ "x-real-ip": "203.0.113.1",
12
+ "user-agent": "test-agent",
13
+ });
14
+
15
+ const auditMeta = extractAuditMeta(headers, "198.51.100.7");
16
+
17
+ expect(auditMeta.ip).toBe("198.51.100.7");
18
+ expect(auditMeta.ip).not.toBe("203.0.113.1");
19
+ expect(auditMeta.userAgent).toBe("test-agent");
20
+ });
21
+
22
+ test("maps the resolver's unknown sentinel to null instead of storing the literal string", () => {
23
+ const auditMeta = extractAuditMeta(new Headers(), UNKNOWN_CLIENT_IP);
24
+
25
+ expect(auditMeta.ip).toBeNull();
26
+ });
27
+
28
+ test("reads user-agent from headers independently of the resolved IP", () => {
29
+ const auditMeta = extractAuditMeta(
30
+ new Headers({ "user-agent": "spoofable-but-fine" }),
31
+ "1.2.3.4",
32
+ );
33
+
34
+ expect(auditMeta.userAgent).toBe("spoofable-but-fine");
35
+ });
36
+ });
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.320.0",
4
+ "type": "fix",
5
+ "title": "Audit IP comes from the framework's trustedProxyHops-aware resolver, not a self-parsed X-Forwarded-For",
6
+ "detail": "`extractAuditMeta` no longer reads `X-Forwarded-For`/`X-Real-IP` itself —\nit takes the `clientIp` the `/user-export/by-token` httpRoute handler now\nreceives from `HttpRouteHandlerDeps`, mapping the resolver's `unknown`\nsentinel to `null`. Closes the spoofed-first-XFF-entry gap for that\nroute. A caller invoking `/api/query`'s `download-by-token` handler\ndirectly (not through this httpRoute) can still pass its own `auditMeta`\nin the payload — a pre-existing, documented tradeoff (the handler's own\ncomment: audit data isn't security-relevant), unchanged by this fix."
7
+ },
2
8
  {
3
9
  "version": "0.313.0",
4
10
  "type": "fix",
@@ -1,4 +1,5 @@
1
1
  // kumiko-lint-ignore app-feature-structure Phase-3 conversion tracked in #2312 (feature.ts > 300 lines)
2
+ import { UNKNOWN_CLIENT_IP } from "@cosmicdrift/kumiko-framework/api";
2
3
  import {
3
4
  createSystemConfig,
4
5
  defineFeature,
@@ -486,10 +487,11 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
486
487
  // as a POST body instead of a query string (issue #1271). A dedicated
487
488
  // route rather than letting the browser JS call /api/query directly,
488
489
  // for two reasons:
489
- // 1. IP/UA audit — extractAuditMeta needs the raw request headers,
490
- // which only the server has, not the browser (see the GET path
491
- // above). Without this route every fragment-path download would
492
- // land in download-attempt with lastUsedFromIp/UA = null.
490
+ // 1. IP/UA audit — extractAuditMeta needs the raw request headers and
491
+ // the framework-resolved client IP, which only the server has, not
492
+ // the browser (see the GET path above). Without this route every
493
+ // fragment-path download would land in download-attempt with
494
+ // lastUsedFromIp/UA = null.
493
495
  // 2. /api/query is the reserved dispatcher namespace for
494
496
  // authenticated writes/queries — an anonymous cross-origin POST
495
497
  // there isn't guaranteed to be reachable (origin guard).
@@ -497,7 +499,7 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
497
499
  method: "POST",
498
500
  path: "/user-export/by-token",
499
501
  anonymous: true,
500
- handler: async (c, { app }) => {
502
+ handler: async (c, { app, clientIp }) => {
501
503
  const url = new URL(c.req.url);
502
504
  const parsedBody = await c.req.json().catch(() => null);
503
505
  const token =
@@ -507,28 +509,29 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
507
509
  if (typeof token !== "string" || token.length === 0) {
508
510
  return c.json({ error: "missing_token" }, 400);
509
511
  }
510
- const auditMeta = extractAuditMeta(c.req.raw.headers);
512
+ const auditMeta = extractAuditMeta(c.req.raw.headers, clientIp);
511
513
  return app.fetch(
512
514
  new Request(`${url.origin}/api/query`, {
513
515
  method: "POST",
514
- headers: {
515
- "content-type": "application/json",
516
- // download-by-token.query.ts's `rateLimit: { per: "ip", limit: 30 }`
517
- // keys off request-id-middleware's own x-forwarded-for read on
518
- // THIS internal request — without forwarding it, every fragment-
519
- // POST download shares the request's own (empty/localhost) IP,
520
- // turning the per-user 30/min backstop into one global bucket
521
- // (#1307). Forwarding the same value extractAuditMeta already
522
- // computed from the original request's headers doesn't widen the
523
- // trust boundary: it's the identical XFF-trusts-first-hop model
524
- // request-id-middleware.ts already applies framework-wide.
525
- ...(auditMeta.ip ? { "x-forwarded-for": auditMeta.ip } : {}),
526
- },
516
+ headers: { "content-type": "application/json" },
527
517
  body: JSON.stringify({
528
518
  type: "user-data-rights:query:download-by-token",
529
519
  payload: { token, auditMeta },
530
520
  }),
531
521
  }),
522
+ // download-by-token.query.ts's `rateLimit: { per: "ip", limit: 30 }`
523
+ // resolves the client IP from this internal request's env (2nd
524
+ // app.fetch arg), same as Bun.serve's socket address — without it,
525
+ // the resolver has neither a trusted XFF entry (trustedProxyHops
526
+ // defaults to 0, ignoring headers entirely) nor a socket, so every
527
+ // fragment-POST download would fall to the shared "unknown" bucket,
528
+ // turning the per-user 30/min backstop into one global bucket an
529
+ // attacker can exhaust for everyone (#1307). Passing `clientIp`
530
+ // directly as env — the same server-resolved, trustedProxyHops-
531
+ // aware value the outer request already trusted — reaches
532
+ // extractSocketAddress() without going through header/hop-count
533
+ // logic at all.
534
+ clientIp,
532
535
  );
533
536
  },
534
537
  });
@@ -727,24 +730,19 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
727
730
  });
728
731
  }
729
732
 
730
- // Extract Audit-Meta (IP + UA) aus den HTTP-Headers + steck es in die
731
- // query-payload. Der httpRoute-Wrapper ist trusted-source — er hat den
732
- // raw-request gesehen, nicht der direkter /api/query-Caller. User der
733
- // /api/query direkt mit eigenem auditMeta aufruft kann luegen, aber
734
- // auditMeta ist nicht security-relevant (operator kann mit server-logs
735
- // crossreferencen wenn forensik gebraucht).
736
- function extractAuditMeta(headers: Headers): { ip: string | null; userAgent: string | null } {
737
- const xff = headers.get("x-forwarded-for");
738
- let ip: string | null = null;
739
- if (xff) {
740
- const first = xff.split(",")[0]?.trim();
741
- if (first && first.length > 0) ip = first;
742
- }
743
- if (!ip) {
744
- const real = headers.get("x-real-ip");
745
- if (real && real.length > 0) ip = real;
746
- }
747
- return { ip, userAgent: headers.get("user-agent") };
733
+ // `clientIp` comes from the server's trustedProxyHops-aware resolver instead
734
+ // of reading X-Forwarded-For here, where a forged first entry would spoof the
735
+ // audit IP. A caller hitting /api/query directly (bypassing this httpRoute
736
+ // wrapper) with its own auditMeta can still lie — accepted, see
737
+ // download-by-token.query.ts.
738
+ export function extractAuditMeta(
739
+ headers: Headers,
740
+ clientIp: string,
741
+ ): { ip: string | null; userAgent: string | null } {
742
+ return {
743
+ ip: clientIp === UNKNOWN_CLIENT_IP ? null : clientIp,
744
+ userAgent: headers.get("user-agent"),
745
+ };
748
746
  }
749
747
 
750
748
  // Interstitial page for the magic-link fragment exchange (see the