@graphoria/server 0.2.3 → 0.3.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 (143) hide show
  1. package/README.md +25 -20
  2. package/dist/src/ai/tools/core.d.ts +6 -1
  3. package/dist/src/ai/tools/core.d.ts.map +1 -1
  4. package/dist/src/ai/tools/core.test.d.ts +2 -0
  5. package/dist/src/ai/tools/core.test.d.ts.map +1 -0
  6. package/dist/src/analyzeQuery/costLimit.d.ts +30 -0
  7. package/dist/src/analyzeQuery/costLimit.d.ts.map +1 -0
  8. package/dist/src/analyzeQuery/costLimit.test.d.ts +2 -0
  9. package/dist/src/analyzeQuery/costLimit.test.d.ts.map +1 -0
  10. package/dist/src/analyzeQuery/depthLimit.d.ts.map +1 -1
  11. package/dist/src/analyzeQuery/fragments.d.ts +6 -0
  12. package/dist/src/analyzeQuery/fragments.d.ts.map +1 -0
  13. package/dist/src/config/types/auth.d.ts +8 -0
  14. package/dist/src/config/types/auth.d.ts.map +1 -1
  15. package/dist/src/config/types/db.d.ts +2 -0
  16. package/dist/src/config/types/db.d.ts.map +1 -1
  17. package/dist/src/config/types/operation.d.ts +4 -0
  18. package/dist/src/config/types/operation.d.ts.map +1 -1
  19. package/dist/src/config/types/operation.test.d.ts +2 -0
  20. package/dist/src/config/types/operation.test.d.ts.map +1 -0
  21. package/dist/src/configuration/crossReferences.d.ts +27 -0
  22. package/dist/src/configuration/crossReferences.d.ts.map +1 -0
  23. package/dist/src/configuration/crossReferences.test.d.ts +2 -0
  24. package/dist/src/configuration/crossReferences.test.d.ts.map +1 -0
  25. package/dist/src/configuration/getSchemas/index.d.ts +70 -4
  26. package/dist/src/configuration/getSchemas/index.d.ts.map +1 -1
  27. package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts +3 -0
  28. package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts.map +1 -1
  29. package/dist/src/configuration/gql/handleGraphQLRequestFactory.d.ts +32 -2
  30. package/dist/src/configuration/gql/handleGraphQLRequestFactory.d.ts.map +1 -1
  31. package/dist/src/configuration/gql/handleGraphQLSubscriptionFactory.d.ts.map +1 -1
  32. package/dist/src/configuration/index.d.ts +41 -2
  33. package/dist/src/configuration/index.d.ts.map +1 -1
  34. package/dist/src/configuration/rest/handleRESTRequestFactory.d.ts.map +1 -1
  35. package/dist/src/configuration/rest/index.d.ts.map +1 -1
  36. package/dist/src/console/api.d.ts +7 -5
  37. package/dist/src/console/api.d.ts.map +1 -1
  38. package/dist/src/console/session.d.ts +21 -0
  39. package/dist/src/console/session.d.ts.map +1 -0
  40. package/dist/src/console/session.test.d.ts +2 -0
  41. package/dist/src/console/session.test.d.ts.map +1 -0
  42. package/dist/src/databases/common.d.ts +12 -4
  43. package/dist/src/databases/common.d.ts.map +1 -1
  44. package/dist/src/databases/core/executor.d.ts +6 -2
  45. package/dist/src/databases/core/executor.d.ts.map +1 -1
  46. package/dist/src/databases/core/function-mapping.d.ts +4 -3
  47. package/dist/src/databases/core/function-mapping.d.ts.map +1 -1
  48. package/dist/src/databases/core/query-builder.d.ts +7 -1
  49. package/dist/src/databases/core/query-builder.d.ts.map +1 -1
  50. package/dist/src/databases/engines/mssql/connection.d.ts +29 -6
  51. package/dist/src/databases/engines/mssql/connection.d.ts.map +1 -1
  52. package/dist/src/databases/engines/mssql/connection.test.d.ts +2 -0
  53. package/dist/src/databases/engines/mssql/connection.test.d.ts.map +1 -0
  54. package/dist/src/databases/engines/mssql/format.d.ts +2 -1
  55. package/dist/src/databases/engines/mssql/format.d.ts.map +1 -1
  56. package/dist/src/databases/engines/mssql/query/index.d.ts +3 -2
  57. package/dist/src/databases/engines/mssql/query/index.d.ts.map +1 -1
  58. package/dist/src/databases/engines/mysql/connection.d.ts +16 -0
  59. package/dist/src/databases/engines/mysql/connection.d.ts.map +1 -1
  60. package/dist/src/databases/engines/mysql/connection.test.d.ts +2 -0
  61. package/dist/src/databases/engines/mysql/connection.test.d.ts.map +1 -0
  62. package/dist/src/databases/engines/mysql/format.d.ts +2 -1
  63. package/dist/src/databases/engines/mysql/format.d.ts.map +1 -1
  64. package/dist/src/databases/engines/mysql/query/index.d.ts +3 -2
  65. package/dist/src/databases/engines/mysql/query/index.d.ts.map +1 -1
  66. package/dist/src/databases/engines/postgresql/connection.d.ts +25 -6
  67. package/dist/src/databases/engines/postgresql/connection.d.ts.map +1 -1
  68. package/dist/src/databases/engines/postgresql/connection.test.d.ts +2 -0
  69. package/dist/src/databases/engines/postgresql/connection.test.d.ts.map +1 -0
  70. package/dist/src/databases/engines/postgresql/format.d.ts +2 -1
  71. package/dist/src/databases/engines/postgresql/format.d.ts.map +1 -1
  72. package/dist/src/databases/engines/postgresql/query/index.d.ts +3 -2
  73. package/dist/src/databases/engines/postgresql/query/index.d.ts.map +1 -1
  74. package/dist/src/databases/high-level-operations.d.ts +2 -0
  75. package/dist/src/databases/high-level-operations.d.ts.map +1 -1
  76. package/dist/src/databases/transformers/data-transformers.d.ts +2 -0
  77. package/dist/src/databases/transformers/data-transformers.d.ts.map +1 -1
  78. package/dist/src/index.d.ts.map +1 -1
  79. package/dist/src/singletons/env.d.ts +19 -0
  80. package/dist/src/singletons/env.d.ts.map +1 -1
  81. package/dist/src/singletons/queryTimeout.d.ts +3 -0
  82. package/dist/src/singletons/queryTimeout.d.ts.map +1 -0
  83. package/dist/src/subscriptions/strategies/database.d.ts.map +1 -1
  84. package/dist/src/subscriptions/strategies/database.test.d.ts +2 -0
  85. package/dist/src/subscriptions/strategies/database.test.d.ts.map +1 -0
  86. package/dist/src/subscriptions/types.d.ts +4 -1
  87. package/dist/src/subscriptions/types.d.ts.map +1 -1
  88. package/dist/src/subscriptions/utils/polling.d.ts.map +1 -1
  89. package/dist/src/types/env.d.ts +73 -0
  90. package/dist/src/types/env.d.ts.map +1 -1
  91. package/dist/src/types/zod/auth.d.ts +12 -0
  92. package/dist/src/types/zod/auth.d.ts.map +1 -1
  93. package/dist/src/types/zod/configuration.d.ts +28 -0
  94. package/dist/src/types/zod/configuration.d.ts.map +1 -1
  95. package/dist/src/utils/rateLimit.d.ts +65 -0
  96. package/dist/src/utils/rateLimit.d.ts.map +1 -0
  97. package/dist/src/utils/rateLimit.test.d.ts +2 -0
  98. package/dist/src/utils/rateLimit.test.d.ts.map +1 -0
  99. package/dist/src/utils/responses.d.ts +3 -0
  100. package/dist/src/utils/responses.d.ts.map +1 -1
  101. package/dist/tsconfig.tsbuildinfo +1 -1
  102. package/package.json +1 -1
  103. package/playgrounds/console/index.html +6 -6
  104. package/playgrounds/graphiql/index.html +136 -136
  105. package/src/ai/tools/core.ts +30 -6
  106. package/src/analyzeQuery/costLimit.ts +236 -0
  107. package/src/analyzeQuery/depthLimit.ts +4 -23
  108. package/src/analyzeQuery/fragments.ts +18 -0
  109. package/src/config/types/auth.ts +7 -0
  110. package/src/config/types/db.ts +7 -0
  111. package/src/config/types/operation.ts +4 -0
  112. package/src/configuration/crossReferences.ts +340 -0
  113. package/src/configuration/getSchemas/type-definition-generator/index.ts +1 -1
  114. package/src/configuration/gql/buildExecute.ts +1 -1
  115. package/src/configuration/gql/handleGraphQLRequestFactory.ts +80 -10
  116. package/src/configuration/gql/handleGraphQLSubscriptionFactory.ts +22 -2
  117. package/src/configuration/index.ts +24 -6
  118. package/src/configuration/rest/handleRESTRequestFactory.ts +6 -4
  119. package/src/configuration/rest/index.ts +11 -2
  120. package/src/console/api.ts +51 -10
  121. package/src/console/session.ts +125 -0
  122. package/src/databases/common.ts +55 -10
  123. package/src/databases/core/executor.ts +12 -2
  124. package/src/databases/core/function-mapping.ts +5 -0
  125. package/src/databases/core/query-builder.ts +7 -0
  126. package/src/databases/engines/mssql/connection.ts +66 -14
  127. package/src/databases/engines/mssql/format.ts +3 -1
  128. package/src/databases/engines/mssql/query/index.ts +12 -2
  129. package/src/databases/engines/mysql/connection.ts +30 -13
  130. package/src/databases/engines/mysql/format.ts +3 -1
  131. package/src/databases/engines/mysql/query/index.ts +25 -4
  132. package/src/databases/engines/postgresql/connection.ts +76 -15
  133. package/src/databases/engines/postgresql/format.ts +3 -1
  134. package/src/databases/engines/postgresql/query/index.ts +12 -3
  135. package/src/databases/high-level-operations.ts +13 -2
  136. package/src/index.ts +82 -13
  137. package/src/singletons/queryTimeout.ts +20 -0
  138. package/src/subscriptions/strategies/database.ts +19 -3
  139. package/src/subscriptions/types.ts +4 -1
  140. package/src/subscriptions/utils/polling.ts +6 -3
  141. package/src/types/env.ts +21 -1
  142. package/src/utils/rateLimit.ts +209 -0
  143. package/src/utils/responses.ts +10 -0
@@ -2,6 +2,7 @@ import type { ServerWebSocket } from "bun";
2
2
  import type { AnalysisResult, SelectionAnalysis, VariableDefinition } from "../analyzeQuery/types";
3
3
  import type { GetSchemaReturn } from "../configuration/getSchemas";
4
4
  import type { createQueryEventEmitter } from "../utils/event-emitter";
5
+ import type { SessionContext } from "../utils/sessionVariables";
5
6
 
6
7
  /**
7
8
  * Type for the query event emitter instance
@@ -24,7 +25,7 @@ export interface SubscriptionContext {
24
25
  ws: ServerWebSocket<unknown>;
25
26
  /** Unique subscription ID from the client */
26
27
  subscriptionId: string;
27
- /** Analyzed GraphQL query */
28
+ /** Analyzed GraphQL query, with variables and `$session.*` already resolved */
28
29
  analysis: AnalysisResult;
29
30
  /** The first field being subscribed to */
30
31
  field: SelectionAnalysis;
@@ -34,6 +35,8 @@ export interface SubscriptionContext {
34
35
  variables: Record<string, unknown>;
35
36
  /** Schema and entity information for the current role */
36
37
  schemaEntity: GetSchemaReturn;
38
+ /** The caller this subscription belongs to. Load-bearing: it keys the group. */
39
+ session: SessionContext;
37
40
  /** Shared event emitter for broadcasting updates */
38
41
  eventEmitter: QueryEventEmitter;
39
42
  }
@@ -5,6 +5,7 @@ import type { DatabasePoller, QueryEventEmitter } from "../types";
5
5
 
6
6
  import { executeQuery, executeQueryJSON, generateSQL } from "../../databases";
7
7
  import { logger } from "../../logging";
8
+ import { env } from "../../singletons/env";
8
9
 
9
10
  export interface DatabasePollerConfig {
10
11
  analysis: AnalysisResult;
@@ -60,9 +61,11 @@ export const createDatabasePoller = async (
60
61
  const firstFieldName = getFirstFieldName(analysis);
61
62
  const db = schemaEntity.queriesMap[firstFieldName!]!.db!;
62
63
 
63
- // Generate SQL queries (data query and hash query)
64
- const [[, queryData]] = generateSQL(schemaEntity, analysis, variables);
65
- const [[, queryHash]] = generateSQL(schemaEntity, analysis, variables, true);
64
+ // Generate SQL queries (data query and hash query). A subscription query is
65
+ // caller-authored, so it carries the same page bounds an HTTP query does.
66
+ const pageLimits = { defaultPageSize: env.defaultPageSize, maxPageSize: env.maxPageSize };
67
+ const [[, queryData]] = generateSQL(schemaEntity, analysis, variables, false, pageLimits);
68
+ const [[, queryHash]] = generateSQL(schemaEntity, analysis, variables, true, pageLimits);
66
69
 
67
70
  // Get initial hash and send initial data
68
71
  let previousHash = await getResultHash(queryHash, db, variableDefinitions, variables);
package/src/types/env.ts CHANGED
@@ -30,12 +30,21 @@ export const EnvZod = z
30
30
  // Boolean flags use stringbool, not coerce.boolean: Boolean("false") === true would flip them on
31
31
  CONSOLE_ENABLED: z.stringbool().default(false),
32
32
  CONSOLE_ENDPOINT: z.string().default("/_console"),
33
+ CONSOLE_SESSION_EXPIRES_IN: z.string().default("1h"),
33
34
  PREFIX: z.string().default(""),
34
35
  CORS_ENABLED: z.stringbool().default(true),
35
36
  PRINT_SCHEMAS: z.stringbool().default(false),
36
37
  SCHEMAS_OUTPUT_DIR: z.string().default("./.graphoria/schemas"),
37
38
  QUERY_ON_RESPONSE: z.stringbool().default(false),
38
- MAX_QUERY_DEPTH: z.coerce.number().int().min(0).default(0),
39
+ MAX_QUERY_DEPTH: z.coerce.number().int().min(0).default(8),
40
+ DEFAULT_PAGE_SIZE: z.coerce.number().int().min(0).default(100),
41
+ MAX_PAGE_SIZE: z.coerce.number().int().min(0).default(1000),
42
+ QUERY_TIMEOUT_MS: z.coerce.number().int().min(0).default(10000),
43
+ MAX_QUERY_COST: z.coerce.number().int().min(0).default(0),
44
+ RATE_LIMIT_MAX: z.coerce.number().int().min(0).default(0),
45
+ RATE_LIMIT_ANONYMOUS_MAX: z.coerce.number().int().min(0).default(0),
46
+ RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60000),
47
+ RATE_LIMIT_TRUST_PROXY: z.stringbool().default(false),
39
48
  AI_ENABLED: z.stringbool().optional(),
40
49
  AI_GRAPHQL_ENABLED: z.stringbool().default(true),
41
50
  AI_REST_ENABLED: z.stringbool().default(true),
@@ -66,6 +75,10 @@ export const EnvZod = z
66
75
  authStrategy: env.AUTH_STRATEGY,
67
76
  queryOnResponse: env.QUERY_ON_RESPONSE,
68
77
  maxQueryDepth: env.MAX_QUERY_DEPTH,
78
+ defaultPageSize: env.DEFAULT_PAGE_SIZE,
79
+ maxPageSize: env.MAX_PAGE_SIZE,
80
+ queryTimeoutMs: env.QUERY_TIMEOUT_MS,
81
+ maxQueryCost: env.MAX_QUERY_COST,
69
82
  admin: {
70
83
  secret: env.ADMIN_SECRET,
71
84
  header: env.ADMIN_SECRET_HEADER,
@@ -80,6 +93,12 @@ export const EnvZod = z
80
93
  secretKey: env.PASETO_SECRET_KEY,
81
94
  publicKey: env.PASETO_PUBLIC_KEY,
82
95
  },
96
+ rateLimit: {
97
+ max: env.RATE_LIMIT_MAX,
98
+ anonymousMax: env.RATE_LIMIT_ANONYMOUS_MAX,
99
+ windowMs: env.RATE_LIMIT_WINDOW_MS,
100
+ trustProxy: env.RATE_LIMIT_TRUST_PROXY,
101
+ },
83
102
  cache: {
84
103
  store: env.CACHE_STORE,
85
104
  redisUrl: env.REDIS_URL,
@@ -94,6 +113,7 @@ export const EnvZod = z
94
113
  console: {
95
114
  enabled: env.CONSOLE_ENABLED,
96
115
  endpoint: env.CONSOLE_ENDPOINT,
116
+ sessionExpiresIn: env.CONSOLE_SESSION_EXPIRES_IN,
97
117
  },
98
118
  ai: {
99
119
  enabled: env.AI_ENABLED,
@@ -0,0 +1,209 @@
1
+ import { LRUCache } from "lru-cache";
2
+
3
+ import { logger } from "../logging";
4
+
5
+ export type ConsumeResult = { allowed: boolean; retryAfterMs: number };
6
+
7
+ /**
8
+ * One method, everything per call: a role with its own limit shares the store
9
+ * with every other role rather than needing an instance of its own.
10
+ */
11
+ export interface RateLimitStore {
12
+ consume(
13
+ key: string,
14
+ capacity: number,
15
+ refillPerMs: number,
16
+ ttlMs: number,
17
+ ): Promise<ConsumeResult>;
18
+ }
19
+
20
+ type Bucket = { tokens: number; ts: number };
21
+
22
+ export type MemoryRateLimitStoreOptions = {
23
+ now?: () => number;
24
+ maxKeys?: number;
25
+ };
26
+
27
+ const DEFAULT_MAX_KEYS = 10_000;
28
+
29
+ export const createMemoryRateLimitStore = ({
30
+ now = Date.now,
31
+ maxKeys = DEFAULT_MAX_KEYS,
32
+ }: MemoryRateLimitStoreOptions = {}): RateLimitStore => {
33
+ // The bound is load-bearing: an unbounded map keyed by client address is
34
+ // itself a memory-exhaustion vector. Evicting a bucket only ever gives its
35
+ // owner a full one, which is what an idle caller would have had anyway.
36
+ const buckets = new LRUCache<string, Bucket>({ max: maxKeys });
37
+
38
+ return {
39
+ consume: async (key, capacity, refillPerMs, ttlMs) => {
40
+ const nowMs = now();
41
+ const bucket = buckets.get(key);
42
+ const tokens = bucket
43
+ ? Math.min(capacity, bucket.tokens + (nowMs - bucket.ts) * refillPerMs)
44
+ : capacity;
45
+
46
+ if (tokens < 1) {
47
+ buckets.set(key, { tokens, ts: nowMs }, { ttl: ttlMs });
48
+ return { allowed: false, retryAfterMs: Math.ceil((1 - tokens) / refillPerMs) };
49
+ }
50
+
51
+ buckets.set(key, { tokens: tokens - 1, ts: nowMs }, { ttl: ttlMs });
52
+ return { allowed: true, retryAfterMs: 0 };
53
+ },
54
+ };
55
+ };
56
+
57
+ export type RateLimitRedisClient = {
58
+ send(command: string, args: string[]): Promise<unknown>;
59
+ };
60
+
61
+ type RateLimitLogger = { warn(obj: object, msg: string): void };
62
+
63
+ export type RedisRateLimitStoreOptions = {
64
+ now?: () => number;
65
+ log?: RateLimitLogger;
66
+ };
67
+
68
+ // KEYS[1] = bucket key, ARGV = capacity, refillPerMs, nowMs, ttlMs. Refill,
69
+ // read and write have to happen inside Redis: doing them from here would race
70
+ // between workers, which is the whole reason for reaching for Redis at all.
71
+ const CONSUME_SCRIPT = `
72
+ local b = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
73
+ local tokens = tonumber(b[1]) or tonumber(ARGV[1])
74
+ local ts = tonumber(b[2]) or tonumber(ARGV[3])
75
+ tokens = math.min(tonumber(ARGV[1]), tokens + (tonumber(ARGV[3]) - ts) * tonumber(ARGV[2]))
76
+ local allowed = 0
77
+ if tokens >= 1 then tokens = tokens - 1; allowed = 1 end
78
+ redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', ARGV[3])
79
+ redis.call('PEXPIRE', KEYS[1], ARGV[4])
80
+ return { allowed, tostring(tokens) }
81
+ `;
82
+
83
+ export const createRedisRateLimitStore = (
84
+ client: RateLimitRedisClient,
85
+ { now = Date.now, log = logger("rate-limit") }: RedisRateLimitStoreOptions = {},
86
+ ): RateLimitStore => {
87
+ let lastWarnMs: number | undefined;
88
+
89
+ return {
90
+ consume: async (key, capacity, refillPerMs, ttlMs) => {
91
+ const nowMs = now();
92
+
93
+ try {
94
+ const reply = (await client.send("EVAL", [
95
+ CONSUME_SCRIPT,
96
+ "1",
97
+ key,
98
+ String(capacity),
99
+ String(refillPerMs),
100
+ String(nowMs),
101
+ String(ttlMs),
102
+ ])) as [number | string, string];
103
+
104
+ const allowed = Number(reply[0]) === 1;
105
+ const tokens = Number(reply[1]);
106
+
107
+ return allowed
108
+ ? { allowed: true, retryAfterMs: 0 }
109
+ : { allowed: false, retryAfterMs: Math.ceil((1 - tokens) / refillPerMs) };
110
+ } catch (error) {
111
+ // A dead cache must not take the API down, and a dead cache must not
112
+ // produce a log line per request either — the flood would outlast the
113
+ // outage.
114
+ if (lastWarnMs === undefined || nowMs - lastWarnMs >= ttlMs) {
115
+ lastWarnMs = nowMs;
116
+ log.warn({ err: error }, "rate limit store unavailable, allowing the request");
117
+ }
118
+ return { allowed: true, retryAfterMs: 0 };
119
+ }
120
+ },
121
+ };
122
+ };
123
+
124
+ export type RateLimitSettings = {
125
+ max: number;
126
+ anonymousMax: number;
127
+ windowMs: number;
128
+ trustProxy: boolean;
129
+ };
130
+
131
+ type RoleRateLimit = { rateLimit?: { max: number; windowMs?: number } | undefined };
132
+
133
+ export type RateLimiter = {
134
+ check(
135
+ session: { role?: string | undefined; sub?: string | undefined } | null,
136
+ address: string | undefined,
137
+ ): Promise<ConsumeResult>;
138
+ };
139
+
140
+ export type CreateRateLimiterOptions = {
141
+ settings: RateLimitSettings;
142
+ anonymousRole: string;
143
+ permissions?: Record<string, RoleRateLimit | undefined>;
144
+ /** A factory is only called once a limit is configured, so a disabled
145
+ * limiter never constructs a store — or a Redis client. */
146
+ store?: RateLimitStore | (() => RateLimitStore);
147
+ };
148
+
149
+ // Attacker-controlled once RATE_LIMIT_TRUST_PROXY is on, and it ends up in a
150
+ // store key, so it is capped before it gets there.
151
+ const MAX_ADDRESS_LENGTH = 64;
152
+
153
+ export const resolveClientAddress = (
154
+ req: Request,
155
+ server: { requestIP(req: Request): { address: string } | null } | undefined,
156
+ trustProxy: boolean,
157
+ ): string | undefined => {
158
+ if (trustProxy) {
159
+ const forwarded = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
160
+ if (forwarded) return forwarded.slice(0, MAX_ADDRESS_LENGTH);
161
+ }
162
+
163
+ return server?.requestIP(req)?.address;
164
+ };
165
+
166
+ /**
167
+ * `undefined` when nothing is configured, so the caller can skip the wrapper
168
+ * entirely and the shipped default costs nothing per request.
169
+ */
170
+ export const createRateLimiter = ({
171
+ settings,
172
+ anonymousRole,
173
+ permissions = {},
174
+ store,
175
+ }: CreateRateLimiterOptions): RateLimiter | undefined => {
176
+ const configured =
177
+ settings.max > 0 ||
178
+ settings.anonymousMax > 0 ||
179
+ Object.values(permissions).some((permission) => (permission?.rateLimit?.max ?? 0) > 0);
180
+
181
+ if (!configured) return undefined;
182
+
183
+ const resolvedStore =
184
+ typeof store === "function" ? store() : (store ?? createMemoryRateLimitStore());
185
+
186
+ const limitFor = (role: string) => {
187
+ const roleLimit = permissions[role]?.rateLimit;
188
+
189
+ return {
190
+ max: roleLimit?.max ?? (role === anonymousRole ? settings.anonymousMax : settings.max),
191
+ windowMs: roleLimit?.windowMs ?? settings.windowMs,
192
+ };
193
+ };
194
+
195
+ return {
196
+ check: async (session, address) => {
197
+ const role = session?.role ?? anonymousRole;
198
+ const { max, windowMs } = limitFor(role);
199
+
200
+ if (max === 0) return { allowed: true, retryAfterMs: 0 };
201
+
202
+ const sub = session?.sub;
203
+ const key =
204
+ sub && sub !== "anonymous" ? `rl:${role}:${sub}` : `rl:${role}:ip:${address ?? "unknown"}`;
205
+
206
+ return resolvedStore.consume(key, max, max / windowMs, windowMs);
207
+ },
208
+ };
209
+ };
@@ -35,6 +35,16 @@ export class S404 extends ClientResponse {
35
35
  }
36
36
  }
37
37
 
38
+ export class S429 extends ClientResponse {
39
+ constructor(retryAfterMs: number) {
40
+ super({ errors: [{ message: "Rate limit exceeded" }] }, { status: 429 });
41
+
42
+ // Seconds, and never 0: a caller told to retry immediately retries
43
+ // immediately, which is the traffic the limit is there to stop.
44
+ this.headers.set("Retry-After", String(Math.max(1, Math.ceil(retryAfterMs / 1000))));
45
+ }
46
+ }
47
+
38
48
  export class S500 extends ClientResponse {
39
49
  constructor(body?: object | null, init?: ResponseInit) {
40
50
  super(body, { ...init, status: 500 });