@kelpie/server 0.1.0 → 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 (69) hide show
  1. package/dist/app.d.ts +11 -0
  2. package/dist/app.d.ts.map +1 -1
  3. package/dist/app.js +27 -0
  4. package/dist/app.js.map +1 -1
  5. package/dist/index.d.ts +3 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +1 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/lib/config.d.ts +8 -0
  10. package/dist/lib/config.d.ts.map +1 -1
  11. package/dist/lib/config.js +5 -0
  12. package/dist/lib/config.js.map +1 -1
  13. package/dist/lib/errors.d.ts +1 -0
  14. package/dist/lib/errors.d.ts.map +1 -1
  15. package/dist/lib/errors.js +3 -0
  16. package/dist/lib/errors.js.map +1 -1
  17. package/dist/lib/ids.d.ts +5 -0
  18. package/dist/lib/ids.d.ts.map +1 -1
  19. package/dist/lib/ids.js +5 -0
  20. package/dist/lib/ids.js.map +1 -1
  21. package/dist/lib/rateLimit.d.ts +26 -0
  22. package/dist/lib/rateLimit.d.ts.map +1 -0
  23. package/dist/lib/rateLimit.js +26 -0
  24. package/dist/lib/rateLimit.js.map +1 -0
  25. package/dist/lib/securityHeaders.d.ts +3 -0
  26. package/dist/lib/securityHeaders.d.ts.map +1 -0
  27. package/dist/lib/securityHeaders.js +41 -0
  28. package/dist/lib/securityHeaders.js.map +1 -0
  29. package/dist/modules/rate-limit/middleware.d.ts +42 -0
  30. package/dist/modules/rate-limit/middleware.d.ts.map +1 -0
  31. package/dist/modules/rate-limit/middleware.js +127 -0
  32. package/dist/modules/rate-limit/middleware.js.map +1 -0
  33. package/dist/modules/rate-limit/repository.d.ts +32 -0
  34. package/dist/modules/rate-limit/repository.d.ts.map +1 -0
  35. package/dist/modules/rate-limit/repository.js +48 -0
  36. package/dist/modules/rate-limit/repository.js.map +1 -0
  37. package/dist/modules/rate-limit/schema.d.ts +125 -0
  38. package/dist/modules/rate-limit/schema.d.ts.map +1 -0
  39. package/dist/modules/rate-limit/schema.js +29 -0
  40. package/dist/modules/rate-limit/schema.js.map +1 -0
  41. package/dist/schema/index.d.ts +1 -0
  42. package/dist/schema/index.d.ts.map +1 -1
  43. package/dist/schema/index.js +1 -0
  44. package/dist/schema/index.js.map +1 -1
  45. package/dist/testing/app.d.ts +5 -0
  46. package/dist/testing/app.d.ts.map +1 -1
  47. package/dist/testing/app.js +14 -0
  48. package/dist/testing/app.js.map +1 -1
  49. package/dist/webBundle.d.ts +37 -0
  50. package/dist/webBundle.d.ts.map +1 -0
  51. package/dist/webBundle.js +75 -0
  52. package/dist/webBundle.js.map +1 -0
  53. package/migrations/0016_misty_mentor.sql +11 -0
  54. package/migrations/meta/0016_snapshot.json +5154 -0
  55. package/migrations/meta/_journal.json +7 -0
  56. package/package.json +3 -2
  57. package/src/app.ts +46 -0
  58. package/src/index.ts +4 -1
  59. package/src/lib/config.ts +13 -0
  60. package/src/lib/errors.ts +4 -0
  61. package/src/lib/ids.ts +5 -0
  62. package/src/lib/rateLimit.ts +45 -0
  63. package/src/lib/securityHeaders.ts +44 -0
  64. package/src/modules/rate-limit/middleware.ts +177 -0
  65. package/src/modules/rate-limit/repository.ts +64 -0
  66. package/src/modules/rate-limit/schema.ts +34 -0
  67. package/src/schema/index.ts +1 -0
  68. package/src/testing/app.ts +21 -0
  69. package/src/webBundle.ts +110 -0
@@ -113,6 +113,13 @@
113
113
  "when": 1786278323968,
114
114
  "tag": "0015_peaceful_chimera",
115
115
  "breakpoints": true
116
+ },
117
+ {
118
+ "idx": 16,
119
+ "version": "7",
120
+ "when": 1786410884983,
121
+ "tag": "0016_misty_mentor",
122
+ "breakpoints": true
116
123
  }
117
124
  ]
118
125
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kelpie/server",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "The Kelpie service as a library: module runtime, core CRM modules, REST API, MCP surface, and the shared migration pipeline.",
5
5
  "keywords": [
6
6
  "kelpie",
@@ -51,7 +51,8 @@
51
51
  "db:generate": "node --env-file-if-exists=../../.env --env-file-if-exists=../../.env.local ../../node_modules/drizzle-kit/bin.cjs generate"
52
52
  },
53
53
  "dependencies": {
54
- "@kelpie/schemas": "^0.1.0",
54
+ "@hono/node-server": "^2.0.12",
55
+ "@kelpie/schemas": "^0.3.0",
55
56
  "@node-rs/argon2": "^2.0.2",
56
57
  "drizzle-orm": "^0.45.2",
57
58
  "hono": "^4.12.32",
package/src/app.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Hono } from 'hono'
2
+ import type { Context } from 'hono'
2
3
  import { cors } from 'hono/cors'
3
4
 
4
5
  import type { DatabaseProbe } from './lib/database.ts'
@@ -7,9 +8,15 @@ import { PUBLIC_ROUTE_PREFIX } from './lib/http.ts'
7
8
  import { createIdFactory } from './lib/ids.ts'
8
9
  import type { IdFactory } from './lib/ids.ts'
9
10
  import type { Logger } from './lib/logger.ts'
11
+ import type { RateLimitConfig } from './lib/rateLimit.ts'
12
+ import { securityHeadersMiddleware } from './lib/securityHeaders.ts'
10
13
  import type { CredentialDependencies } from './modules/auth/credentials.ts'
11
14
  import { MCP_INSTRUCTIONS, MCP_ROUTE_PREFIX, MCP_SERVER_INFO } from './modules/mcp/index.ts'
12
15
  import { createMcpEndpoint } from './modules/mcp/router.ts'
16
+ import {
17
+ createAuthAndApiRateLimitMiddleware,
18
+ createFormSubmitRateLimitMiddleware,
19
+ } from './modules/rate-limit/middleware.ts'
13
20
  import { createIdempotencyMiddleware } from './modules/workspace/idempotencyMiddleware.ts'
14
21
  import type { ModuleContributions } from './runtime/registry.ts'
15
22
 
@@ -34,6 +41,15 @@ export interface AppDependencies {
34
41
  readonly generateRequestId?: () => string
35
42
  /** Injected so tests can pin the ids `idempotencyMiddleware` reserves rows under. */
36
43
  readonly createId?: IdFactory
44
+ readonly rateLimit: RateLimitConfig
45
+ /**
46
+ * Required rather than defaulted: a wrong-but-plausible fallback (trusting a
47
+ * spoofable header, say) would degrade the rate limiter silently instead of
48
+ * failing to build. The real entry points resolve this from the actual
49
+ * socket (`apps/kelpie/src/server.ts`); tests resolve it from a header they
50
+ * control (`testing/app.ts`).
51
+ */
52
+ readonly resolveClientIp: (context: Context) => string
37
53
  }
38
54
 
39
55
  /** Per-request values the middleware chain sets and handlers read. */
@@ -87,6 +103,23 @@ export function createApp(dependencies: AppDependencies): Hono<AppBindings> {
87
103
  })
88
104
  })
89
105
 
106
+ app.use('*', securityHeadersMiddleware)
107
+
108
+ const rateLimitDependencies = {
109
+ db: dependencies.credentials.db,
110
+ now: dependencies.credentials.now,
111
+ createId,
112
+ credentials: dependencies.credentials,
113
+ resolveClientIp: dependencies.resolveClientIp,
114
+ config: dependencies.rateLimit,
115
+ }
116
+
117
+ // Ahead of idempotency, so a rate-limited request never reserves an
118
+ // idempotency key it will not be allowed to spend. Skips `/v1/public/*`
119
+ // itself; `createFormSubmitRateLimitMiddleware` below covers it, mounted
120
+ // where the public CORS middleware requires.
121
+ app.use('/v1/*', createAuthAndApiRateLimitMiddleware(rateLimitDependencies))
122
+
90
123
  // Every module's `POST` gets this the same way, decided once here rather than
91
124
  // per route (`api.md`). It skips `/v1/public/*` itself — a public request has
92
125
  // no `Actor` to scope a key to — so it is mounted ahead of the public CORS
@@ -107,6 +140,11 @@ export function createApp(dependencies: AppDependencies): Hono<AppBindings> {
107
140
  // middleware is attached before anything can answer under the prefix.
108
141
  app.use(`${PUBLIC_ROUTE_PREFIX}/*`, PUBLIC_CORS)
109
142
 
143
+ // After CORS, not before: a throw ahead of it would strip the CORS headers a
144
+ // cross-origin embed needs to read the 429 body, and would apply to the
145
+ // preflight `OPTIONS` request CORS itself answers without reaching here.
146
+ app.use(`${PUBLIC_ROUTE_PREFIX}/*`, createFormSubmitRateLimitMiddleware(rateLimitDependencies))
147
+
110
148
  for (const { router } of dependencies.contributions.publicRouters) {
111
149
  app.route(PUBLIC_ROUTE_PREFIX, router)
112
150
  }
@@ -123,6 +161,14 @@ export function createApp(dependencies: AppDependencies): Hono<AppBindings> {
123
161
  logger: dependencies.logger,
124
162
  })
125
163
 
164
+ // The transport takes bearer keys only (`api.md`), so every call that
165
+ // reaches it is already the `api_key` traffic the `api` budget above
166
+ // exists for. Shared with `/v1` rather than a separate budget: one key's
167
+ // usage is one thing to protect the workspace from, whichever surface it
168
+ // comes through. No CORS layer to mind the order of here, unlike the forms
169
+ // budget — the transport checks its own `Origin` inside the handler.
170
+ app.use(MCP_ROUTE_PREFIX, createAuthAndApiRateLimitMiddleware(rateLimitDependencies))
171
+
126
172
  app.route(MCP_ROUTE_PREFIX, mcp.transport)
127
173
  app.route('/v1', mcp.catalog)
128
174
 
package/src/index.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  export { createApp } from './app.ts'
2
- export type { AppDependencies } from './app.ts'
2
+ export type { AppDependencies, AppBindings } from './app.ts'
3
+
4
+ export { WebBundleError, serveWebBundle } from './webBundle.ts'
5
+ export type { WebBundleOptions } from './webBundle.ts'
3
6
 
4
7
  export { ConfigurationError, loadConfig } from './lib/config.ts'
5
8
  export type { Environment, KelpieConfig, LogLevel, RuntimeMode } from './lib/config.ts'
package/src/lib/config.ts CHANGED
@@ -3,6 +3,8 @@ import { z } from 'zod'
3
3
  import { emailConfigSchema } from './email.ts'
4
4
  import type { EmailConfig } from './email.ts'
5
5
  import { describeValidationIssue } from './errors.ts'
6
+ import { rateLimitConfigFrom, rateLimitConfigSchema } from './rateLimit.ts'
7
+ import type { RateLimitConfig } from './rateLimit.ts'
6
8
 
7
9
  /**
8
10
  * The single place the service reads environment variables. Every other module
@@ -29,6 +31,13 @@ export interface KelpieConfig {
29
31
  * settings decide.
30
32
  */
31
33
  readonly moduleConfigPath: string | undefined
34
+ /**
35
+ * Directory holding the built web bundle, served from the same origin as the
36
+ * API (`webBundle.ts`). Undefined is the development case: the Vite dev server
37
+ * builds the pages and proxies the API, so there is no bundle on disk to serve.
38
+ */
39
+ readonly webBundleDirectory: string | undefined
40
+ readonly rateLimit: RateLimitConfig
32
41
  }
33
42
 
34
43
  /** Thrown at boot when the environment cannot produce a valid configuration. */
@@ -59,7 +68,9 @@ const environmentSchema = z.object({
59
68
  .refine(isPostgresUrl, { message: 'must be a postgres:// or postgresql:// connection string' }),
60
69
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']),
61
70
  KELPIE_MODULE_CONFIG_PATH: z.string().min(1).optional(),
71
+ WEB_BUNDLE_DIR: z.string().min(1).optional(),
62
72
  ...emailConfigSchema.shape,
73
+ ...rateLimitConfigSchema.shape,
63
74
  })
64
75
 
65
76
  /**
@@ -82,5 +93,7 @@ export function loadConfig(environment: Environment): KelpieConfig {
82
93
  logLevel: result.data.LOG_LEVEL,
83
94
  email: { EMAIL_PROVIDER: result.data.EMAIL_PROVIDER, EMAIL_FROM: result.data.EMAIL_FROM },
84
95
  moduleConfigPath: result.data.KELPIE_MODULE_CONFIG_PATH,
96
+ webBundleDirectory: result.data.WEB_BUNDLE_DIR,
97
+ rateLimit: rateLimitConfigFrom(result.data),
85
98
  }
86
99
  }
package/src/lib/errors.ts CHANGED
@@ -77,6 +77,10 @@ export class AppError extends Error {
77
77
  static conflict(message: string, details?: readonly ErrorDetail[]): AppError {
78
78
  return new AppError('conflict', message, details)
79
79
  }
80
+
81
+ static rateLimited(message = 'Too many requests'): AppError {
82
+ return new AppError('rate_limited', message)
83
+ }
80
84
  }
81
85
 
82
86
  /**
package/src/lib/ids.ts CHANGED
@@ -46,6 +46,11 @@ export const idPrefixes = {
46
46
  * database is generated the same, injectable way.
47
47
  */
48
48
  idempotencyKey: 'idem',
49
+ /**
50
+ * Not returned over the wire either, for the same reason as `idempotencyKey`:
51
+ * a bucket is bookkeeping the rate limiter owns, not a resource with routes.
52
+ */
53
+ rateLimitBucket: 'rl',
49
54
  } as const
50
55
 
51
56
  export type ObjectKind = keyof typeof idPrefixes
@@ -0,0 +1,45 @@
1
+ import { z } from 'zod'
2
+
3
+ /**
4
+ * Budgets for the three request classes `api.md` rate-limits: public form
5
+ * submissions, unauthenticated auth endpoints, and everything else under an
6
+ * API key. Roadmap Phase 6: self-host packaging calls for this with nothing to
7
+ * configure, so every variable here is optional and defaulted.
8
+ */
9
+
10
+ export interface RateLimitBudget {
11
+ readonly limit: number
12
+ readonly windowMs: number
13
+ }
14
+
15
+ export interface RateLimitConfig {
16
+ readonly forms: RateLimitBudget
17
+ readonly auth: RateLimitBudget
18
+ readonly api: RateLimitBudget
19
+ }
20
+
21
+ export const rateLimitConfigSchema = z.object({
22
+ RATE_LIMIT_FORMS_LIMIT: z.coerce.number().int().positive().default(20),
23
+ RATE_LIMIT_FORMS_WINDOW_SECONDS: z.coerce.number().int().positive().default(60),
24
+ RATE_LIMIT_AUTH_LIMIT: z.coerce.number().int().positive().default(10),
25
+ RATE_LIMIT_AUTH_WINDOW_SECONDS: z.coerce.number().int().positive().default(60),
26
+ RATE_LIMIT_API_LIMIT: z.coerce.number().int().positive().default(600),
27
+ RATE_LIMIT_API_WINDOW_SECONDS: z.coerce.number().int().positive().default(60),
28
+ })
29
+
30
+ export function rateLimitConfigFrom(parsed: z.infer<typeof rateLimitConfigSchema>): RateLimitConfig {
31
+ return {
32
+ forms: {
33
+ limit: parsed.RATE_LIMIT_FORMS_LIMIT,
34
+ windowMs: parsed.RATE_LIMIT_FORMS_WINDOW_SECONDS * 1000,
35
+ },
36
+ auth: {
37
+ limit: parsed.RATE_LIMIT_AUTH_LIMIT,
38
+ windowMs: parsed.RATE_LIMIT_AUTH_WINDOW_SECONDS * 1000,
39
+ },
40
+ api: {
41
+ limit: parsed.RATE_LIMIT_API_LIMIT,
42
+ windowMs: parsed.RATE_LIMIT_API_WINDOW_SECONDS * 1000,
43
+ },
44
+ }
45
+ }
@@ -0,0 +1,44 @@
1
+ import { secureHeaders } from 'hono/secure-headers'
2
+ import type { MiddlewareHandler } from 'hono'
3
+
4
+ /**
5
+ * `api.md`'s security headers, in two layers.
6
+ *
7
+ * `hono/secure-headers` (bundled with `hono` already; no new dependency)
8
+ * covers `Strict-Transport-Security`, `X-Content-Type-Options` and
9
+ * `Referrer-Policy`. Every other header it can add is turned off explicitly
10
+ * rather than left at the library's default, so a future `hono` upgrade
11
+ * cannot silently change what this API sends. `Cross-Origin-Resource-Policy`
12
+ * in particular would break the very cross-origin iframe embedding
13
+ * `forms/embed.ts` exists for, on any customer page that opts into
14
+ * `Cross-Origin-Embedder-Policy` itself.
15
+ *
16
+ * `X-Frame-Options` cannot go through the library the same way: a mount's
17
+ * value is fixed once, not computed per request, and the embed page is the
18
+ * one response in this service that must not send it (`embed.ts:229`). The
19
+ * wrapper below adds it everywhere else, driven by the one signal that tells
20
+ * the embed page apart from every other route: it is the only handler that
21
+ * sets its own `Content-Security-Policy`.
22
+ */
23
+ const baseHeaders = secureHeaders({
24
+ strictTransportSecurity: true,
25
+ xContentTypeOptions: true,
26
+ referrerPolicy: true,
27
+ crossOriginEmbedderPolicy: false,
28
+ crossOriginResourcePolicy: false,
29
+ crossOriginOpenerPolicy: false,
30
+ originAgentCluster: false,
31
+ xDnsPrefetchControl: false,
32
+ xDownloadOptions: false,
33
+ xFrameOptions: false,
34
+ xPermittedCrossDomainPolicies: false,
35
+ xXssProtection: false,
36
+ })
37
+
38
+ export const securityHeadersMiddleware: MiddlewareHandler = async (context, next) => {
39
+ await baseHeaders(context, next)
40
+
41
+ if (context.res.headers.get('Content-Security-Policy') === null) {
42
+ context.header('X-Frame-Options', 'DENY')
43
+ }
44
+ }
@@ -0,0 +1,177 @@
1
+ import type { Context, MiddlewareHandler } from 'hono'
2
+
3
+ import type { Database } from '../../lib/database.ts'
4
+ import { AppError } from '../../lib/errors.ts'
5
+ import { PUBLIC_ROUTE_PREFIX } from '../../lib/http.ts'
6
+ import type { IdFactory } from '../../lib/ids.ts'
7
+ import type { RateLimitBudget, RateLimitConfig } from '../../lib/rateLimit.ts'
8
+ import { readBearerToken } from '../api-keys/keys.ts'
9
+ import { resolveActorFrom } from '../auth/credentials.ts'
10
+ import type { CredentialDependencies } from '../auth/credentials.ts'
11
+ import { incrementRateLimitBucket, pruneExpiredRateLimitBuckets } from './repository.ts'
12
+
13
+ /**
14
+ * The three budgets `api.md` describes for `/v1`: public form submissions,
15
+ * unauthenticated auth endpoints, and everything else called with an API key.
16
+ * Split into two middlewares rather than one, because the forms budget has to
17
+ * sit *inside* `PUBLIC_CORS` in `app.ts` — a throw ahead of it would strip the
18
+ * CORS headers a cross-origin embed needs to read the 429 body — while the
19
+ * auth and API budgets have no CORS concern and can run earlier.
20
+ */
21
+
22
+ export interface RateLimitMiddlewareDependencies {
23
+ readonly db: Database
24
+ readonly now: () => Date
25
+ readonly createId: IdFactory
26
+ readonly credentials: CredentialDependencies
27
+ readonly resolveClientIp: (context: Context) => string
28
+ readonly config: RateLimitConfig
29
+ }
30
+
31
+ /**
32
+ * Every unauthenticated `/v1/auth/*` endpoint: the ones a stranger can call to
33
+ * attempt credential stuffing or enumerate accounts. `/auth/logout`, `/me`,
34
+ * `/sessions` and changing a known password all require a session already and
35
+ * carry no such risk, so they are not here.
36
+ */
37
+ const UNAUTHENTICATED_AUTH_PATHS: ReadonlySet<string> = new Set([
38
+ '/v1/auth/signup',
39
+ '/v1/auth/login',
40
+ '/v1/auth/password-reset',
41
+ '/v1/auth/password-reset/confirm',
42
+ ])
43
+
44
+ function isFormSubmitRoute(context: Context): boolean {
45
+ return context.req.method === 'POST' && context.req.path.endsWith('/submit')
46
+ }
47
+
48
+ /** Floors `now` to the start of its fixed window, so every caller in the same window shares one row. */
49
+ function windowStart(now: Date, windowMs: number): Date {
50
+ return new Date(Math.floor(now.getTime() / windowMs) * windowMs)
51
+ }
52
+
53
+ /**
54
+ * The largest configured window across all three budgets. A bucket cannot be
55
+ * expired before its own window elapses, so this is a safe cutoff for
56
+ * pruning regardless of which budget created the row.
57
+ */
58
+ function maxWindowMs(config: RateLimitConfig): number {
59
+ return Math.max(config.forms.windowMs, config.auth.windowMs, config.api.windowMs)
60
+ }
61
+
62
+ /**
63
+ * Increments the bucket and throws `429` with `Retry-After` once the caller is
64
+ * over budget. Pruning runs only when this request started a fresh window
65
+ * (`count === 1`) rather than on every call, which ties cleanup frequency to
66
+ * how often new windows open instead of to raw request volume.
67
+ */
68
+ async function enforceBudget(
69
+ dependencies: RateLimitMiddlewareDependencies,
70
+ context: Context,
71
+ params: { readonly scope: string; readonly key: string; readonly budget: RateLimitBudget },
72
+ ): Promise<void> {
73
+ const now = dependencies.now()
74
+ const start = windowStart(now, params.budget.windowMs)
75
+
76
+ const count = await incrementRateLimitBucket(dependencies.db, {
77
+ id: dependencies.createId('rateLimitBucket'),
78
+ scope: params.scope,
79
+ key: params.key,
80
+ windowStart: start,
81
+ })
82
+
83
+ if (count === 1) {
84
+ await pruneExpiredRateLimitBuckets(dependencies.db, new Date(now.getTime() - maxWindowMs(dependencies.config)))
85
+ }
86
+
87
+ if (count > params.budget.limit) {
88
+ const resetAt = start.getTime() + params.budget.windowMs
89
+ const retryAfterSeconds = Math.max(1, Math.ceil((resetAt - now.getTime()) / 1000))
90
+
91
+ context.header('Retry-After', String(retryAfterSeconds))
92
+ throw AppError.rateLimited()
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Guards `POST /v1/public/forms/:key/submit`, keyed by the caller's IP.
98
+ *
99
+ * Mount this **after** `PUBLIC_CORS` in `app.ts`. It only recognises the
100
+ * submit route; every other path under the public prefix, including the embed
101
+ * page itself, passes through untouched.
102
+ */
103
+ export function createFormSubmitRateLimitMiddleware(
104
+ dependencies: RateLimitMiddlewareDependencies,
105
+ ): MiddlewareHandler {
106
+ return async (context, next) => {
107
+ if (!isFormSubmitRoute(context)) {
108
+ await next()
109
+ return
110
+ }
111
+
112
+ await enforceBudget(dependencies, context, {
113
+ scope: 'forms',
114
+ key: dependencies.resolveClientIp(context),
115
+ budget: dependencies.config.forms,
116
+ })
117
+ await next()
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Guards the unauthenticated auth endpoints (by IP) and every other `/v1/*`
123
+ * request made with an API key (by that key) — the budget that protects a
124
+ * workspace rather than the world. Session traffic, the product's own UI,
125
+ * carries no budget here.
126
+ *
127
+ * Public routes are skipped: `createFormSubmitRateLimitMiddleware` already
128
+ * covers them, positioned where `PUBLIC_CORS` requires. Mount this one
129
+ * anywhere else on `/v1/*`, ahead of idempotency so a rate-limited request
130
+ * never reserves an idempotency key it will not be allowed to spend.
131
+ */
132
+ export function createAuthAndApiRateLimitMiddleware(
133
+ dependencies: RateLimitMiddlewareDependencies,
134
+ ): MiddlewareHandler {
135
+ return async (context, next) => {
136
+ const path = context.req.path
137
+
138
+ if (path === PUBLIC_ROUTE_PREFIX || path.startsWith(`${PUBLIC_ROUTE_PREFIX}/`)) {
139
+ await next()
140
+ return
141
+ }
142
+
143
+ if (context.req.method === 'POST' && UNAUTHENTICATED_AUTH_PATHS.has(path)) {
144
+ await enforceBudget(dependencies, context, {
145
+ scope: 'auth',
146
+ key: dependencies.resolveClientIp(context),
147
+ budget: dependencies.config.auth,
148
+ })
149
+ await next()
150
+ return
151
+ }
152
+
153
+ // Only a bearer credential can resolve to an `api_key` actor, the only
154
+ // kind this budget applies to, so a cookie-only request — the ordinary
155
+ // browser session — never pays for an actor resolution here at all.
156
+ const bearer = readBearerToken(context.req.header('Authorization'))
157
+
158
+ if (bearer === undefined || bearer.length === 0) {
159
+ await next()
160
+ return
161
+ }
162
+
163
+ const actor = await resolveActorFrom(dependencies.credentials, context)
164
+
165
+ if (actor.kind !== 'api_key') {
166
+ await next()
167
+ return
168
+ }
169
+
170
+ await enforceBudget(dependencies, context, {
171
+ scope: 'api',
172
+ key: actor.apiKeyId,
173
+ budget: dependencies.config.api,
174
+ })
175
+ await next()
176
+ }
177
+ }
@@ -0,0 +1,64 @@
1
+ import { lt, sql } from 'drizzle-orm'
2
+
3
+ import type { Database } from '../../lib/database.ts'
4
+ import type { Transaction } from '../../runtime/transaction.ts'
5
+ import { rateLimitBuckets } from './schema.ts'
6
+
7
+ /** Queries for `rate_limit_buckets`. The middleware decides; these read and write. */
8
+
9
+ export type Queryable = Database | Transaction
10
+
11
+ export type RateLimitBucketRecord = typeof rateLimitBuckets.$inferSelect
12
+
13
+ /**
14
+ * Increments the counter for `(scope, key, windowStart)`, creating the row on
15
+ * the first request of a window.
16
+ *
17
+ * One atomic upsert. Postgres's own row lock under `ON CONFLICT` is what makes
18
+ * concurrent requests from the same caller count correctly with no read
19
+ * followed by a separate write to race against — the same shape
20
+ * `reserveIdempotencyKey` uses, and for the same reason: two statements can't
21
+ * be made atomic against each other, but one can.
22
+ */
23
+ export async function incrementRateLimitBucket(
24
+ db: Queryable,
25
+ params: { readonly id: string; readonly scope: string; readonly key: string; readonly windowStart: Date },
26
+ ): Promise<number> {
27
+ const [row] = await db
28
+ .insert(rateLimitBuckets)
29
+ .values({
30
+ id: params.id,
31
+ scope: params.scope,
32
+ key: params.key,
33
+ windowStart: params.windowStart,
34
+ count: 1,
35
+ })
36
+ .onConflictDoUpdate({
37
+ target: [rateLimitBuckets.scope, rateLimitBuckets.key, rateLimitBuckets.windowStart],
38
+ set: { count: sql`${rateLimitBuckets.count} + 1` },
39
+ })
40
+ .returning({ count: rateLimitBuckets.count })
41
+
42
+ if (row === undefined) {
43
+ throw new Error('rate limit bucket upsert returned no row')
44
+ }
45
+
46
+ return row.count
47
+ }
48
+
49
+ /**
50
+ * Sweeps buckets from windows that have fully elapsed.
51
+ *
52
+ * Enforced here rather than by a schedule, the same call `idempotency_keys`
53
+ * and `webhook_deliveries` make about their own growth: there is no scheduler
54
+ * in the service, so the middleware prunes on every increment
55
+ * (`middleware.ts`) and the table stays bounded by the traffic that grows it.
56
+ */
57
+ export async function pruneExpiredRateLimitBuckets(db: Queryable, before: Date): Promise<number> {
58
+ const deleted = await db
59
+ .delete(rateLimitBuckets)
60
+ .where(lt(rateLimitBuckets.windowStart, before))
61
+ .returning({ id: rateLimitBuckets.id })
62
+
63
+ return deleted.length
64
+ }
@@ -0,0 +1,34 @@
1
+ import { index, integer, pgTable, text, unique } from 'drizzle-orm/pg-core'
2
+
3
+ import { createdAt, moment, primaryId } from '../../lib/columns.ts'
4
+
5
+ /**
6
+ * Fixed-window counters backing the rate limiter (`app.ts`). Not
7
+ * workspace-owned, unlike every other core table: a public form submission or
8
+ * an unauthenticated auth attempt has no workspace to scope to, which is
9
+ * exactly the traffic this table exists to police. `scope` names the budget
10
+ * (`forms`, `auth`, `api`) and `key` is the caller within it — an IP address
11
+ * or an API key id.
12
+ *
13
+ * `(scope, key, window_start)` is unique rather than `(scope, key)` alone
14
+ * because the window boundary lives in the key: a new window is a new row, so
15
+ * incrementing is one atomic upsert (`repository.ts`) with no read before the
16
+ * write to race against. A row is left for `pruneExpiredRateLimitBuckets` to
17
+ * sweep once its window has elapsed rather than deleted eagerly, since nothing
18
+ * reads a bucket after that.
19
+ */
20
+ export const rateLimitBuckets = pgTable(
21
+ 'rate_limit_buckets',
22
+ {
23
+ id: primaryId(),
24
+ scope: text('scope').notNull(),
25
+ key: text('key').notNull(),
26
+ windowStart: moment('window_start').notNull(),
27
+ count: integer('count').notNull().default(1),
28
+ createdAt: createdAt(),
29
+ },
30
+ (table) => [
31
+ unique('rate_limit_buckets_scope_key_window_key').on(table.scope, table.key, table.windowStart),
32
+ index('rate_limit_buckets_window_start_idx').on(table.windowStart),
33
+ ],
34
+ )
@@ -28,3 +28,4 @@ export * from '../modules/import-export/schema.ts'
28
28
  export * from '../modules/agent-tasks/schema.ts'
29
29
  export * from '../modules/webhooks/schema.ts'
30
30
  export * from '../modules/integrations/schema.ts'
31
+ export * from '../modules/rate-limit/schema.ts'
@@ -6,6 +6,8 @@ import type { Actor } from '../lib/actor.ts'
6
6
  import type { Environment } from '../lib/config.ts'
7
7
  import type { DatabaseProbe } from '../lib/database.ts'
8
8
  import { createLogger } from '../lib/logger.ts'
9
+ import { rateLimitConfigFrom, rateLimitConfigSchema } from '../lib/rateLimit.ts'
10
+ import type { RateLimitConfig } from '../lib/rateLimit.ts'
9
11
  import type { KelpieModule } from '../runtime/module.ts'
10
12
  import type { ModuleContributions } from '../runtime/registry.ts'
11
13
  import type { EntitlementRegistry } from '../runtime/entitlements.ts'
@@ -14,6 +16,19 @@ import { TEST_ENVIRONMENT } from './environment.ts'
14
16
  import { createTestServices } from './services.ts'
15
17
  import type { TestServices } from './services.ts'
16
18
 
19
+ /** The same defaults `loadConfig` produces from an empty environment: one source of numbers for both. */
20
+ const DEFAULT_TEST_RATE_LIMIT: RateLimitConfig = rateLimitConfigFrom(rateLimitConfigSchema.parse({}))
21
+
22
+ /**
23
+ * A caller's IP, for tests. Real entry points resolve this from the socket
24
+ * (`apps/kelpie/src/server.ts`); a test using `app.request()` has no socket, so
25
+ * it reads `X-Forwarded-For` when a test sets one to simulate distinct
26
+ * callers, falling back to a fixed address for everything else.
27
+ */
28
+ function testClientIp(context: Context): string {
29
+ return context.req.header('X-Forwarded-For') ?? '203.0.113.1'
30
+ }
31
+
17
32
  /**
18
33
  * Assembles an app for tests. Unlike the real boot it defaults every dependency,
19
34
  * because a test that has to spell out an environment it does not care about
@@ -38,6 +53,10 @@ export interface TestAppOptions {
38
53
  * route behaves as it did before module toggling existed.
39
54
  */
40
55
  readonly resolveActor?: (context: Context) => Promise<Actor>
56
+ /** Defaults to the same numbers `loadConfig` would, from an empty environment. */
57
+ readonly rateLimit?: RateLimitConfig
58
+ /** Defaults to reading `X-Forwarded-For`, so a test can simulate distinct callers. */
59
+ readonly resolveClientIp?: (context: Context) => string
41
60
  }
42
61
 
43
62
  export interface TestApp {
@@ -73,6 +92,8 @@ export async function createTestApp(options: TestAppOptions = {}): Promise<TestA
73
92
  credentials: { db: services.db, now: services.now },
74
93
  generateRequestId: options.generateRequestId ?? (() => 'req-test'),
75
94
  createId: services.createId,
95
+ rateLimit: options.rateLimit ?? DEFAULT_TEST_RATE_LIMIT,
96
+ resolveClientIp: options.resolveClientIp ?? testClientIp,
76
97
  })
77
98
 
78
99
  return { app, contributions, logLines, services }