@getstrata/core 0.5.101 → 0.7.4

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 (61) hide show
  1. package/CHANGELOG.md +67 -32
  2. package/README.md +16 -35
  3. package/dist/core/auth/abilityCatalog.d.ts +2 -2
  4. package/dist/core/auth/basicAuthGuard.d.ts +9 -0
  5. package/dist/core/auth/guard.d.ts +6 -0
  6. package/dist/core/auth/jwt.d.ts +19 -0
  7. package/dist/core/auth/jwtGuard.d.ts +14 -0
  8. package/dist/core/auth/tokenAbilityChecker.d.ts +5 -0
  9. package/dist/core/cache/tags.d.ts +6 -0
  10. package/dist/core/contracts/authUserDirectory.d.ts +4 -0
  11. package/dist/core/database/dialect.d.ts +18 -0
  12. package/dist/core/database/factory.d.ts +1 -0
  13. package/dist/core/database/index.d.ts +8 -0
  14. package/dist/core/database/mysqlConnection.d.ts +12 -0
  15. package/dist/core/database/namedConnections.d.ts +15 -0
  16. package/dist/core/database/repositoryQuery.d.ts +1 -0
  17. package/dist/core/database/sqliteConnection.d.ts +7 -0
  18. package/dist/core/http/loginThrottleMiddleware.d.ts +5 -2
  19. package/dist/core/http/resources.d.ts +2 -2
  20. package/dist/core/http/response.d.ts +2 -1
  21. package/dist/core/http/statelessAuth.d.ts +8 -0
  22. package/dist/core/http/throttleResponse.d.ts +2 -0
  23. package/dist/core/runtime/frontendMode.d.ts +10 -2
  24. package/dist/entries/auth/basicAuthGuard.js +137 -0
  25. package/dist/entries/auth/jwt.js +135 -0
  26. package/dist/entries/auth/jwtGuard.js +203 -0
  27. package/dist/entries/auth/sessionGuard.js +3 -21
  28. package/dist/entries/auth/tokenAbilityChecker.js +24 -0
  29. package/dist/entries/cache/tags.js +7 -1
  30. package/dist/entries/database/connectionContext.js +1 -0
  31. package/dist/entries/database/dialect.js +1 -0
  32. package/dist/entries/database/factory.js +5 -4
  33. package/dist/entries/database/model.js +49 -32
  34. package/dist/entries/database/mysqlConnection.js +35 -0
  35. package/dist/entries/database/namedConnections.js +1 -0
  36. package/dist/entries/database/query.js +28 -15
  37. package/dist/entries/database/relationships.js +14 -6
  38. package/dist/entries/database/repositoryQuery.js +96 -81
  39. package/dist/entries/database/schema.js +28 -15
  40. package/dist/entries/database/sqliteConnection.js +34 -0
  41. package/dist/entries/facades.js +1 -1
  42. package/dist/entries/http/contentNegotiation.js +5 -2
  43. package/dist/entries/http/csrfMiddleware.js +45 -0
  44. package/dist/entries/http/loginThrottleMiddleware.js +246 -7
  45. package/dist/entries/http/memoryThrottleMiddleware.js +208 -6
  46. package/dist/entries/http/requireAbilityMiddleware.js +35 -11
  47. package/dist/entries/http/requirePasswordConfirmMiddleware.js +5 -2
  48. package/dist/entries/http/requireVerifiedMiddleware.js +5 -2
  49. package/dist/entries/http/requireWebAuthMiddleware.js +12 -3
  50. package/dist/entries/http/resources.js +4 -1
  51. package/dist/entries/http/response.js +46 -12
  52. package/dist/entries/http/statelessAuth.js +48 -0
  53. package/dist/entries/http/throttleMiddleware.js +208 -6
  54. package/dist/entries/http/webErrorResponse.js +35 -11
  55. package/dist/entries/http/webFormRequest.js +5 -2
  56. package/dist/entries/mail/mailer.js +1 -1
  57. package/dist/entries/openapi/generator.js +26 -3
  58. package/dist/entries/runtime/frontendMode.js +39 -10
  59. package/dist/framework/public-api.d.ts +11 -1
  60. package/dist/index.js +873 -270
  61. package/package.json +56 -5
@@ -267,11 +267,12 @@ class UnsupportedSchemaFeatureError extends Error {
267
267
  }
268
268
  }
269
269
  // ../../src/core/database/query.ts
270
+ import { currentSqlDialect } from "@getstrata/core/database/dialect";
270
271
  function quoteIdentifier(identifier) {
271
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) {
272
- throw new Error(`Invalid SQL identifier: ${identifier}`);
273
- }
274
- return `"${identifier}"`;
272
+ return currentSqlDialect().quoteIdentifier(identifier);
273
+ }
274
+ function returningSuffix(columns) {
275
+ return currentSqlDialect().returningClause(columns);
275
276
  }
276
277
  function qualifyColumn(tableName, column) {
277
278
  return `${quoteIdentifier(tableName)}.${quoteIdentifier(column)}`;
@@ -301,7 +302,7 @@ function isQueryOperator(value) {
301
302
  }
302
303
  function pushParam(values, value) {
303
304
  values.push(value);
304
- return `$${values.length}`;
305
+ return currentSqlDialect().placeholder(values.length);
305
306
  }
306
307
  function buildInClause(column, values, params) {
307
308
  if (values.length === 0) {
@@ -341,9 +342,12 @@ function buildOperatorClauses(column, operator, params) {
341
342
  clauses.push(`${column} <= ${pushParam(params, operator.lte)}`);
342
343
  }
343
344
  if (operator.ilike !== undefined) {
344
- clauses.push(`${column} ILIKE ${pushParam(params, operator.ilike)}`);
345
+ clauses.push(`${column} ${currentSqlDialect().ilikeOperator()} ${pushParam(params, operator.ilike)}`);
345
346
  }
346
347
  if (operator.tsMatch !== undefined) {
348
+ if (currentSqlDialect().driver !== "pgsql") {
349
+ throw new Error("Full-text search (tsMatch) is only available on PostgreSQL.");
350
+ }
347
351
  clauses.push(`${column} @@ plainto_tsquery('english', ${pushParam(params, operator.tsMatch)})`);
348
352
  }
349
353
  return clauses;
@@ -542,7 +546,7 @@ function buildSelectList(table, select, params = []) {
542
546
  return item.as ? `${column2} AS ${quoteIdentifier(item.as)}` : column2;
543
547
  }
544
548
  if (item.kind === "literalText") {
545
- return `${pushParam(params, item.value)}::text AS ${quoteIdentifier(item.as)}`;
549
+ return `${currentSqlDialect().castToText(pushParam(params, item.value))} AS ${quoteIdentifier(item.as)}`;
546
550
  }
547
551
  const column = qualifyColumn(item.table, item.column);
548
552
  const placeholder = pushParam(params, item.query);
@@ -634,7 +638,7 @@ function buildInsertQuery(table, values) {
634
638
  const placeholders = entries.map(([, value]) => pushParam(params, value)).join(", ");
635
639
  const returningColumns = buildReturningColumns(table);
636
640
  return {
637
- text: `INSERT INTO ${quoteIdentifier(table.name)} (${columns}) VALUES (${placeholders}) RETURNING ${returningColumns}`,
641
+ text: `INSERT INTO ${quoteIdentifier(table.name)} (${columns}) VALUES (${placeholders})${returningSuffix(returningColumns)}`,
638
642
  params
639
643
  };
640
644
  }
@@ -653,7 +657,7 @@ function buildUpdateQuery(table, id, changes) {
653
657
  appendSoftDeleteScope(table, {}, scopeClauses);
654
658
  const scopeSuffix = scopeClauses.length > 0 ? ` AND ${scopeClauses.join(" AND ")}` : "";
655
659
  return {
656
- text: `UPDATE ${quoteIdentifier(table.name)} SET ${setClause} WHERE ${quoteIdentifier(table.primaryKey)} = ${primaryKeyPlaceholder}${scopeSuffix} RETURNING ${returningColumns}`,
660
+ text: `UPDATE ${quoteIdentifier(table.name)} SET ${setClause} WHERE ${quoteIdentifier(table.primaryKey)} = ${primaryKeyPlaceholder}${scopeSuffix}${returningSuffix(returningColumns)}`,
657
661
  params
658
662
  };
659
663
  }
@@ -666,9 +670,12 @@ function buildSoftDeleteByIdQuery(table, id, deletedAt) {
666
670
  const scopeClauses = [];
667
671
  appendSoftDeleteScope(table, {}, scopeClauses);
668
672
  const scopeSuffix = scopeClauses.length > 0 ? ` AND ${scopeClauses.join(" AND ")}` : "";
673
+ const params = [];
674
+ const deletedAtPlaceholder = pushParam(params, deletedAt);
675
+ const idPlaceholder = pushParam(params, id);
669
676
  return {
670
- text: `UPDATE ${quoteIdentifier(table.name)} SET ${quoteIdentifier(deletedAtColumn)} = $1 WHERE ${quoteIdentifier(table.primaryKey)} = $2${scopeSuffix} RETURNING ${returningColumns}`,
671
- params: [deletedAt, id]
677
+ text: `UPDATE ${quoteIdentifier(table.name)} SET ${quoteIdentifier(deletedAtColumn)} = ${deletedAtPlaceholder} WHERE ${quoteIdentifier(table.primaryKey)} = ${idPlaceholder}${scopeSuffix}${returningSuffix(returningColumns)}`,
678
+ params
672
679
  };
673
680
  }
674
681
  function buildRestoreByIdQuery(table, id) {
@@ -677,15 +684,21 @@ function buildRestoreByIdQuery(table, id) {
677
684
  throw new Error(`Table ${table.name} does not support soft deletes.`);
678
685
  }
679
686
  const returningColumns = buildReturningColumns(table);
687
+ const params = [];
688
+ const deletedAtPlaceholder = pushParam(params, null);
689
+ const idPlaceholder = pushParam(params, id);
680
690
  return {
681
- text: `UPDATE ${quoteIdentifier(table.name)} SET ${quoteIdentifier(deletedAtColumn)} = $1 WHERE ${quoteIdentifier(table.primaryKey)} = $2 AND ${qualifyColumn(table.name, deletedAtColumn)} IS NOT NULL RETURNING ${returningColumns}`,
682
- params: [null, id]
691
+ text: `UPDATE ${quoteIdentifier(table.name)} SET ${quoteIdentifier(deletedAtColumn)} = ${deletedAtPlaceholder} WHERE ${quoteIdentifier(table.primaryKey)} = ${idPlaceholder} AND ${qualifyColumn(table.name, deletedAtColumn)} IS NOT NULL${returningSuffix(returningColumns)}`,
692
+ params
683
693
  };
684
694
  }
685
695
  function buildDeleteByIdQuery(table, id) {
696
+ const params = [];
697
+ const idPlaceholder = pushParam(params, id);
698
+ const returning = returningSuffix(`${quoteIdentifier(table.primaryKey)} AS ${quoteIdentifier("deleted_id")}`);
686
699
  return {
687
- text: `DELETE FROM ${quoteIdentifier(table.name)} WHERE ${quoteIdentifier(table.primaryKey)} = $1 RETURNING ${quoteIdentifier(table.primaryKey)} AS ${quoteIdentifier("deleted_id")}`,
688
- params: [id]
700
+ text: `DELETE FROM ${quoteIdentifier(table.name)} WHERE ${quoteIdentifier(table.primaryKey)} = ${idPlaceholder}${returning}`,
701
+ params
689
702
  };
690
703
  }
691
704
 
@@ -0,0 +1,34 @@
1
+ // @bun
2
+ // ../../src/core/database/sqliteConnection.ts
3
+ import { Database } from "bun:sqlite";
4
+ function isRowReturning(sql) {
5
+ const upper = sql.replace(/\s+/g, " ").trim().toUpperCase();
6
+ if (upper.includes(" RETURNING ")) {
7
+ return true;
8
+ }
9
+ return upper.startsWith("SELECT") || upper.startsWith("WITH") || upper.startsWith("PRAGMA") || upper.startsWith("EXPLAIN");
10
+ }
11
+ function createSqliteConnection(filename) {
12
+ if (!filename.trim()) {
13
+ throw new Error("SQLite path is not configured. Pass a filename or :memory:.");
14
+ }
15
+ const db = new Database(filename, { create: true });
16
+ db.exec("PRAGMA foreign_keys = ON");
17
+ return {
18
+ async unsafe(query, params = []) {
19
+ const statement = db.query(query);
20
+ const args = [...params];
21
+ if (isRowReturning(query)) {
22
+ return statement.all(...args);
23
+ }
24
+ statement.run(...args);
25
+ return [];
26
+ },
27
+ close() {
28
+ db.close();
29
+ }
30
+ };
31
+ }
32
+ export {
33
+ createSqliteConnection
34
+ };
@@ -226,7 +226,7 @@ class LogMailDriver {
226
226
  to: message.to,
227
227
  subject: message.subject,
228
228
  body: message.body,
229
- ...message.html ? { html: message.html } : {}
229
+ ...message.html ? { htmlBytes: Buffer.byteLength(message.html, "utf8") } : {}
230
230
  }));
231
231
  }
232
232
  }
@@ -7,6 +7,10 @@ function requestPrefersJson(request) {
7
7
  if (request.headers.get("HX-Request") === "true") {
8
8
  return false;
9
9
  }
10
+ const pathname = new URL(request.url).pathname;
11
+ if (pathname.startsWith("/api/")) {
12
+ return true;
13
+ }
10
14
  const accept = request.headers.get("accept")?.toLowerCase() ?? "";
11
15
  if (accept.includes("text/html")) {
12
16
  return false;
@@ -18,8 +22,7 @@ function requestPrefersJson(request) {
18
22
  if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
19
23
  return false;
20
24
  }
21
- const pathname = new URL(request.url).pathname;
22
- return pathname.startsWith("/api/");
25
+ return false;
23
26
  }
24
27
  export {
25
28
  requestPrefersJson
@@ -195,6 +195,48 @@ function resolveCsrfTokenForRequest(request) {
195
195
  return resolveCsrfToken(request).token;
196
196
  }
197
197
 
198
+ // ../../src/core/http/statelessAuth.ts
199
+ function authorizationScheme(request) {
200
+ const header = request.headers.get("authorization")?.trim() ?? "";
201
+ const scheme = header.split(/\s+/, 1)[0];
202
+ return scheme ? scheme.toLowerCase() : "";
203
+ }
204
+ function requestUsesHeaderCredentials(request) {
205
+ const scheme = authorizationScheme(request);
206
+ return scheme === "bearer" || scheme === "basic";
207
+ }
208
+ function readBearerToken(request) {
209
+ const header = request.headers.get("authorization")?.trim() ?? "";
210
+ if (!header.toLowerCase().startsWith("bearer ")) {
211
+ return null;
212
+ }
213
+ const token = header.slice("Bearer ".length).trim();
214
+ return token.length > 0 ? token : null;
215
+ }
216
+ function readBasicCredentials(request) {
217
+ const header = request.headers.get("authorization")?.trim() ?? "";
218
+ if (!header.toLowerCase().startsWith("basic ")) {
219
+ return null;
220
+ }
221
+ const encoded = header.slice("Basic ".length).trim();
222
+ if (!encoded) {
223
+ return null;
224
+ }
225
+ try {
226
+ const decoded = Buffer.from(encoded, "base64").toString("utf8");
227
+ const separator = decoded.indexOf(":");
228
+ if (separator < 0) {
229
+ return null;
230
+ }
231
+ return {
232
+ username: decoded.slice(0, separator),
233
+ password: decoded.slice(separator + 1)
234
+ };
235
+ } catch {
236
+ return null;
237
+ }
238
+ }
239
+
198
240
  // ../../src/core/http/csrfMiddleware.ts
199
241
  var MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
200
242
  function appendSetCookie(response, cookie) {
@@ -208,6 +250,9 @@ function appendSetCookie(response, cookie) {
208
250
  }
209
251
  function createCsrfMiddleware() {
210
252
  return async (request, next) => {
253
+ if (requestUsesHeaderCredentials(request)) {
254
+ return await next();
255
+ }
211
256
  const method = request.method.toUpperCase();
212
257
  if (!MUTATING_METHODS.has(method)) {
213
258
  const csrf = resolveCsrfToken(request);
@@ -71,7 +71,215 @@ function readClientIp(request, env = process.env) {
71
71
  return request.headers.get("x-real-ip")?.trim() || undefined;
72
72
  }
73
73
 
74
+ // ../../src/core/runtime/frontendMode.ts
75
+ var FRONTEND_MODES = ["api", "server-htmx", "spa-react", "hybrid"];
76
+ var DEFAULT_SPA_PREFIX = "/app";
77
+ var FRONTEND_MODE_PATTERN = new RegExp(`^(${FRONTEND_MODES.join("|")})$`);
78
+ function parseFrontendMode(value) {
79
+ const mode = (value ?? "api").trim();
80
+ if (mode === "server-htmx" || mode === "spa-react" || mode === "hybrid") {
81
+ return mode;
82
+ }
83
+ return "api";
84
+ }
85
+ function readFrontendMode() {
86
+ return parseFrontendMode(process.env.FRONTEND_MODE);
87
+ }
88
+ function isViewsMode(mode) {
89
+ return mode === "server-htmx" || mode === "hybrid";
90
+ }
91
+ function isSpaMode(mode) {
92
+ return mode === "spa-react" || mode === "hybrid";
93
+ }
94
+ function isViewsEnabled() {
95
+ return isViewsMode(readFrontendMode());
96
+ }
97
+ function isSpaEnabled() {
98
+ return isSpaMode(readFrontendMode());
99
+ }
100
+ function normalizeSpaPrefix(value) {
101
+ const raw = (value ?? DEFAULT_SPA_PREFIX).trim() || DEFAULT_SPA_PREFIX;
102
+ const withSlash = raw.startsWith("/") ? raw : `/${raw}`;
103
+ const trimmed = withSlash.replace(/\/+$/, "");
104
+ if (trimmed.length === 0 || trimmed === "/") {
105
+ return DEFAULT_SPA_PREFIX;
106
+ }
107
+ return trimmed;
108
+ }
109
+ function readSpaPrefix() {
110
+ return normalizeSpaPrefix(process.env.SPA_PREFIX);
111
+ }
112
+
113
+ // ../../src/core/view/webErrorView.ts
114
+ import { currentRequestMeta } from "@getstrata/core/http/requestMetaContext";
115
+
116
+ // ../../src/core/view/htmlResponse.ts
117
+ function withCharset(contentType) {
118
+ return contentType.includes("charset=") ? contentType : `${contentType}; charset=utf-8`;
119
+ }
120
+ function htmlResponse(html, init = {}) {
121
+ return new Response(html, {
122
+ status: init.status ?? 200,
123
+ statusText: init.statusText,
124
+ headers: {
125
+ "Content-Type": "text/html; charset=utf-8"
126
+ }
127
+ });
128
+ }
129
+ function isHtmxRequest(request) {
130
+ return request.headers.get("HX-Request") === "true";
131
+ }
132
+ function redirectResponse(location, status = 302) {
133
+ return new Response(null, {
134
+ status,
135
+ headers: {
136
+ Location: location
137
+ }
138
+ });
139
+ }
140
+ function textResponse(body, init = {}) {
141
+ return new Response(body, {
142
+ status: init.status ?? 200,
143
+ headers: {
144
+ "Content-Type": "text/plain; charset=utf-8"
145
+ }
146
+ });
147
+ }
148
+ function xmlResponse(body, init = {}) {
149
+ return new Response(body, {
150
+ status: init.status ?? 200,
151
+ headers: {
152
+ "Content-Type": withCharset(init.contentType ?? "application/xml")
153
+ }
154
+ });
155
+ }
156
+ function rssResponse(body, init = {}) {
157
+ return xmlResponse(body, { ...init, contentType: "application/rss+xml" });
158
+ }
159
+
160
+ // ../../src/core/view/webErrorView.ts
161
+ var configuredErrorView = {};
162
+ function configureWebErrorView(options) {
163
+ configuredErrorView = { ...options };
164
+ }
165
+ function escapeHtml(value) {
166
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
167
+ }
168
+ function renderKernelErrorChrome(input) {
169
+ const title = escapeHtml(input.title);
170
+ const message = escapeHtml(input.message);
171
+ const errorLines = Object.entries(input.errors ?? {}).flatMap(([field, messages]) => messages.map((item) => `${field}: ${item}`)).map((line) => `<li>${escapeHtml(line)}</li>`).join("");
172
+ const details = errorLines ? `<ul class="error-list">${errorLines}</ul>` : "";
173
+ const goBack = input.status === 422 ? `<p><a href="javascript:history.back()">Go back</a></p>` : "";
174
+ return `<!doctype html>
175
+ <html lang="en">
176
+ <head>
177
+ <meta charset="UTF-8" />
178
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
179
+ <title>${title}</title>
180
+ <link rel="stylesheet" href="/assets/app.css" />
181
+ </head>
182
+ <body>
183
+ <header class="site-header">
184
+ <a class="brand" href="/">Home</a>
185
+ </header>
186
+ <main class="site-main">
187
+ <section class="page-header">
188
+ <h1>${title}</h1>
189
+ <p>${message}</p>
190
+ ${details}
191
+ ${goBack}
192
+ </section>
193
+ </main>
194
+ </body>
195
+ </html>
196
+ `;
197
+ }
198
+ function errorTemplateName(status) {
199
+ if (status === 404) {
200
+ return "errors/not-found";
201
+ }
202
+ if (status === 403) {
203
+ return "errors/forbidden";
204
+ }
205
+ return "errors/error";
206
+ }
207
+ async function renderWebErrorHtml(input) {
208
+ const render = configuredErrorView.render;
209
+ if (!render) {
210
+ return renderKernelErrorChrome(input);
211
+ }
212
+ try {
213
+ return await render({
214
+ ...input,
215
+ request: input.request ?? currentRequestMeta().request
216
+ });
217
+ } catch {
218
+ return renderKernelErrorChrome(input);
219
+ }
220
+ }
221
+ async function htmlErrorResponse(input) {
222
+ return htmlResponse(await renderWebErrorHtml(input), { status: input.status });
223
+ }
224
+ async function notFoundHtmlResponse(body) {
225
+ if (body !== undefined) {
226
+ return htmlResponse(body, { status: 404 });
227
+ }
228
+ return htmlErrorResponse({
229
+ status: 404,
230
+ title: "Not Found",
231
+ message: "The page you requested was not found."
232
+ });
233
+ }
234
+
235
+ // ../../src/core/http/contentNegotiation.ts
236
+ function requestPrefersJson(request) {
237
+ if (!request) {
238
+ return true;
239
+ }
240
+ if (request.headers.get("HX-Request") === "true") {
241
+ return false;
242
+ }
243
+ const pathname = new URL(request.url).pathname;
244
+ if (pathname.startsWith("/api/")) {
245
+ return true;
246
+ }
247
+ const accept = request.headers.get("accept")?.toLowerCase() ?? "";
248
+ if (accept.includes("text/html")) {
249
+ return false;
250
+ }
251
+ if (accept.includes("application/json")) {
252
+ return true;
253
+ }
254
+ const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
255
+ if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
256
+ return false;
257
+ }
258
+ return false;
259
+ }
260
+
261
+ // ../../src/core/http/throttleResponse.ts
262
+ async function tooManyRequestsResponse(request, message, decaySeconds) {
263
+ const retryAfter = { "retry-after": String(decaySeconds) };
264
+ if (requestPrefersJson(request) || !isViewsEnabled()) {
265
+ return Response.json({ error: message }, {
266
+ status: 429,
267
+ headers: retryAfter
268
+ });
269
+ }
270
+ const html = await htmlErrorResponse({
271
+ status: 429,
272
+ title: "Too Many Requests",
273
+ message,
274
+ request
275
+ });
276
+ const headers = new Headers(html.headers);
277
+ headers.set("retry-after", String(decaySeconds));
278
+ return new Response(html.body, { status: 429, headers });
279
+ }
280
+
74
281
  // ../../src/core/http/loginThrottleMiddleware.ts
282
+ var memoryLoginBuckets = new Map;
75
283
  function resolveLoginIdentity(request) {
76
284
  return readClientIp(request) ?? "unknown";
77
285
  }
@@ -89,7 +297,30 @@ async function resolveLoginEmail(request) {
89
297
  return "unknown";
90
298
  }
91
299
  }
92
- function createLoginThrottleMiddleware(options) {
300
+ function consumeMemoryAttempt(key, decaySeconds) {
301
+ const now = Date.now();
302
+ const existing = memoryLoginBuckets.get(key);
303
+ if (!existing || existing.resetAt <= now) {
304
+ memoryLoginBuckets.set(key, { count: 1, resetAt: now + decaySeconds * 1000 });
305
+ return 1;
306
+ }
307
+ existing.count += 1;
308
+ return existing.count;
309
+ }
310
+ function createMemoryLoginThrottleMiddleware(options) {
311
+ const prefix = options.keyPrefix ?? namespacedRedisKey("login-throttle:");
312
+ return async (request, next) => {
313
+ const identity = resolveLoginIdentity(request);
314
+ const email = await resolveLoginEmail(request);
315
+ const throttleKey = `${prefix}${identity}:${email}`;
316
+ const attempts = consumeMemoryAttempt(throttleKey, options.decaySeconds);
317
+ if (attempts > options.maxAttempts) {
318
+ return await tooManyRequestsResponse(request, "Too many login attempts. Try again later.", options.decaySeconds);
319
+ }
320
+ return await next();
321
+ };
322
+ }
323
+ function createRedisLoginThrottleMiddleware(options) {
93
324
  const client = new RedisClient(options.redisUrl);
94
325
  const prefix = options.keyPrefix ?? namespacedRedisKey("login-throttle:");
95
326
  return async (request, next) => {
@@ -101,17 +332,25 @@ function createLoginThrottleMiddleware(options) {
101
332
  await client.expire(throttleKey, options.decaySeconds);
102
333
  }
103
334
  if (attempts > options.maxAttempts) {
104
- return Response.json({ error: "Too many login attempts. Try again later." }, {
105
- status: 429,
106
- headers: {
107
- "retry-after": String(options.decaySeconds)
108
- }
109
- });
335
+ return await tooManyRequestsResponse(request, "Too many login attempts. Try again later.", options.decaySeconds);
110
336
  }
111
337
  return await next();
112
338
  };
113
339
  }
340
+ function createLoginThrottleMiddleware(options) {
341
+ const redisUrl = options.redisUrl?.trim() ?? "";
342
+ if (redisUrl) {
343
+ return createRedisLoginThrottleMiddleware({ ...options, redisUrl });
344
+ }
345
+ return createMemoryLoginThrottleMiddleware(options);
346
+ }
347
+ function resetMemoryLoginThrottleForTests() {
348
+ memoryLoginBuckets.clear();
349
+ }
114
350
  export {
115
351
  createLoginThrottleMiddleware,
352
+ createMemoryLoginThrottleMiddleware,
353
+ resetMemoryLoginThrottleForTests,
354
+ resolveLoginEmail,
116
355
  resolveLoginIdentity
117
356
  };