@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.
- package/dist/app.d.ts +11 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +27 -0
- package/dist/app.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/config.d.ts +8 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +5 -0
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/errors.d.ts +1 -0
- package/dist/lib/errors.d.ts.map +1 -1
- package/dist/lib/errors.js +3 -0
- package/dist/lib/errors.js.map +1 -1
- package/dist/lib/ids.d.ts +5 -0
- package/dist/lib/ids.d.ts.map +1 -1
- package/dist/lib/ids.js +5 -0
- package/dist/lib/ids.js.map +1 -1
- package/dist/lib/rateLimit.d.ts +26 -0
- package/dist/lib/rateLimit.d.ts.map +1 -0
- package/dist/lib/rateLimit.js +26 -0
- package/dist/lib/rateLimit.js.map +1 -0
- package/dist/lib/securityHeaders.d.ts +3 -0
- package/dist/lib/securityHeaders.d.ts.map +1 -0
- package/dist/lib/securityHeaders.js +41 -0
- package/dist/lib/securityHeaders.js.map +1 -0
- package/dist/modules/rate-limit/middleware.d.ts +42 -0
- package/dist/modules/rate-limit/middleware.d.ts.map +1 -0
- package/dist/modules/rate-limit/middleware.js +127 -0
- package/dist/modules/rate-limit/middleware.js.map +1 -0
- package/dist/modules/rate-limit/repository.d.ts +32 -0
- package/dist/modules/rate-limit/repository.d.ts.map +1 -0
- package/dist/modules/rate-limit/repository.js +48 -0
- package/dist/modules/rate-limit/repository.js.map +1 -0
- package/dist/modules/rate-limit/schema.d.ts +125 -0
- package/dist/modules/rate-limit/schema.d.ts.map +1 -0
- package/dist/modules/rate-limit/schema.js +29 -0
- package/dist/modules/rate-limit/schema.js.map +1 -0
- package/dist/schema/index.d.ts +1 -0
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +1 -0
- package/dist/schema/index.js.map +1 -1
- package/dist/testing/app.d.ts +5 -0
- package/dist/testing/app.d.ts.map +1 -1
- package/dist/testing/app.js +14 -0
- package/dist/testing/app.js.map +1 -1
- package/dist/webBundle.d.ts +37 -0
- package/dist/webBundle.d.ts.map +1 -0
- package/dist/webBundle.js +75 -0
- package/dist/webBundle.js.map +1 -0
- package/migrations/0016_misty_mentor.sql +11 -0
- package/migrations/meta/0016_snapshot.json +5154 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +3 -2
- package/src/app.ts +46 -0
- package/src/index.ts +4 -1
- package/src/lib/config.ts +13 -0
- package/src/lib/errors.ts +4 -0
- package/src/lib/ids.ts +5 -0
- package/src/lib/rateLimit.ts +45 -0
- package/src/lib/securityHeaders.ts +44 -0
- package/src/modules/rate-limit/middleware.ts +177 -0
- package/src/modules/rate-limit/repository.ts +64 -0
- package/src/modules/rate-limit/schema.ts +34 -0
- package/src/schema/index.ts +1 -0
- package/src/testing/app.ts +21 -0
- package/src/webBundle.ts +110 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kelpie/server",
|
|
3
|
-
"version": "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
|
-
"@
|
|
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
|
+
)
|
package/src/schema/index.ts
CHANGED
package/src/testing/app.ts
CHANGED
|
@@ -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 }
|