File size: 3,276 Bytes
6c3af4e
 
 
 
 
 
 
 
 
 
765119d
 
 
 
6c3af4e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
765119d
6c3af4e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
/**
 * Aggregate counters for the requests that reached token verification.
 *
 * The rate limiter keys on the client IP, so that is the one thing these must
 * not keep: no address, no token, no query, and no timestamp per request. What
 * is left says whether the instance is being abused, whether a client is
 * broken, and how the shared request budget is spent across the endpoints that
 * share it, none of which needs to know who asked.
 */

import {
  getRejectedTokenCacheHits,
  getRejectedTokenCount,
} from "./rejectedTokens.ts";
import {
  RATE_LIMIT_DURATION_SECONDS,
  RATE_LIMIT_POINTS,
  type RejectionReason,
  THUMBNAIL_RATE_LIMIT_DURATION_SECONDS,
  THUMBNAIL_RATE_LIMIT_POINTS,
} from "./verifyTokenAndRateLimit.ts";

/**
 * Which endpoint family a request was aimed at. `other` is what keeps the
 * totals closed when a new endpoint starts using the same verification funnel
 * and nobody extends this list.
 */
export type AuthorizationSurface =
  | "search"
  | "pageContent"
  | "thumbnail"
  | "inference"
  | "other";

interface SurfaceCounts {
  authorized: number;
  rejected: number;
}

let authorized = 0;

const reasons: Record<RejectionReason, number> = {
  rateLimited: 0,
  missingToken: 0,
  invalidToken: 0,
};

const bySurface: Record<AuthorizationSurface, SurfaceCounts> = {
  search: { authorized: 0, rejected: 0 },
  pageContent: { authorized: 0, rejected: 0 },
  thumbnail: { authorized: 0, rejected: 0 },
  inference: { authorized: 0, rejected: 0 },
  other: { authorized: 0, rejected: 0 },
};

export function recordAuthorizedRequest(surface: AuthorizationSurface): void {
  authorized++;
  bySurface[surface].authorized++;
}

export function recordRejectedRequest(
  surface: AuthorizationSurface,
  reason: RejectionReason,
): void {
  reasons[reason]++;
  bySurface[surface].rejected++;
}

/**
 * `authorized` plus every entry of `reasons` sums to `requests`, and each half
 * of `bySurface` sums to its side of that, so a request that goes uncounted
 * shows up as a gap rather than being lost silently.
 *
 * The limiter's own settings ride along because the counts cannot be read
 * without them: 40 rate-limited requests means something different at 10
 * points per 10 seconds than it would at 100.
 */
export function getAuthorizationStats() {
  const rejected = Object.values(reasons).reduce(
    (total, count) => total + count,
    0,
  );
  const requests = authorized + rejected;

  return {
    requests,
    authorized,
    rejectedRate: Number(((rejected / requests) * 100 || 0).toFixed(1)),
    rejectedTokenCacheHits: getRejectedTokenCacheHits(),
    distinctRejectedTokens: getRejectedTokenCount(),
    reasons: { ...reasons },
    // Deep copy, so a caller holding a snapshot for comparison does not watch
    // it change under them as later requests arrive.
    bySurface: Object.fromEntries(
      Object.entries(bySurface).map(([surface, counts]) => [
        surface,
        { ...counts },
      ]),
    ) as Record<AuthorizationSurface, SurfaceCounts>,
    limiter: {
      points: RATE_LIMIT_POINTS,
      durationSeconds: RATE_LIMIT_DURATION_SECONDS,
      thumbnail: {
        points: THUMBNAIL_RATE_LIMIT_POINTS,
        durationSeconds: THUMBNAIL_RATE_LIMIT_DURATION_SECONDS,
      },
    },
  };
}