@kelpie/server 0.2.0 → 0.3.1

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 (85) 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/import-export/drafts.d.ts.map +1 -1
  30. package/dist/modules/import-export/drafts.js +0 -1
  31. package/dist/modules/import-export/drafts.js.map +1 -1
  32. package/dist/modules/rate-limit/middleware.d.ts +42 -0
  33. package/dist/modules/rate-limit/middleware.d.ts.map +1 -0
  34. package/dist/modules/rate-limit/middleware.js +127 -0
  35. package/dist/modules/rate-limit/middleware.js.map +1 -0
  36. package/dist/modules/rate-limit/repository.d.ts +32 -0
  37. package/dist/modules/rate-limit/repository.d.ts.map +1 -0
  38. package/dist/modules/rate-limit/repository.js +48 -0
  39. package/dist/modules/rate-limit/repository.js.map +1 -0
  40. package/dist/modules/rate-limit/schema.d.ts +125 -0
  41. package/dist/modules/rate-limit/schema.d.ts.map +1 -0
  42. package/dist/modules/rate-limit/schema.js +29 -0
  43. package/dist/modules/rate-limit/schema.js.map +1 -0
  44. package/dist/modules/workspace/repository.d.ts +14 -1
  45. package/dist/modules/workspace/repository.d.ts.map +1 -1
  46. package/dist/modules/workspace/repository.js.map +1 -1
  47. package/dist/modules/workspace/service.d.ts +15 -5
  48. package/dist/modules/workspace/service.d.ts.map +1 -1
  49. package/dist/modules/workspace/service.js.map +1 -1
  50. package/dist/runtime/registry.d.ts +14 -4
  51. package/dist/runtime/registry.d.ts.map +1 -1
  52. package/dist/runtime/registry.js.map +1 -1
  53. package/dist/schema/index.d.ts +1 -0
  54. package/dist/schema/index.d.ts.map +1 -1
  55. package/dist/schema/index.js +1 -0
  56. package/dist/schema/index.js.map +1 -1
  57. package/dist/testing/app.d.ts +5 -0
  58. package/dist/testing/app.d.ts.map +1 -1
  59. package/dist/testing/app.js +14 -0
  60. package/dist/testing/app.js.map +1 -1
  61. package/dist/webBundle.d.ts +37 -0
  62. package/dist/webBundle.d.ts.map +1 -0
  63. package/dist/webBundle.js +75 -0
  64. package/dist/webBundle.js.map +1 -0
  65. package/migrations/0016_misty_mentor.sql +11 -0
  66. package/migrations/meta/0016_snapshot.json +5154 -0
  67. package/migrations/meta/_journal.json +7 -0
  68. package/package.json +3 -2
  69. package/src/app.ts +46 -0
  70. package/src/index.ts +4 -1
  71. package/src/lib/config.ts +13 -0
  72. package/src/lib/errors.ts +4 -0
  73. package/src/lib/ids.ts +5 -0
  74. package/src/lib/rateLimit.ts +45 -0
  75. package/src/lib/securityHeaders.ts +44 -0
  76. package/src/modules/import-export/drafts.ts +15 -2
  77. package/src/modules/rate-limit/middleware.ts +177 -0
  78. package/src/modules/rate-limit/repository.ts +64 -0
  79. package/src/modules/rate-limit/schema.ts +34 -0
  80. package/src/modules/workspace/repository.ts +12 -1
  81. package/src/modules/workspace/service.ts +15 -5
  82. package/src/runtime/registry.ts +14 -4
  83. package/src/schema/index.ts +1 -0
  84. package/src/testing/app.ts +21 -0
  85. 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.2.0",
3
+ "version": "0.3.1",
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.2.0",
54
+ "@hono/node-server": "^2.0.12",
55
+ "@kelpie/schemas": "^0.3.1",
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
+ }
@@ -120,8 +120,21 @@ function enumeration(
120
120
  return canonicalEnum(values, mapped[column]) ?? undefined
121
121
  }
122
122
 
123
- /** Drops the keys whose value is undefined, so a draft holds only what it sets. */
124
- function present<T extends object>(draft: T): T {
123
+ /**
124
+ * Drops the keys whose value is undefined, so a draft holds only what it sets.
125
+ *
126
+ * The parameter is not `T`. Under `exactOptionalPropertyTypes` an optional key
127
+ * declared `name?: string` may be absent but may not be present holding
128
+ * `undefined`, and every literal below spells out all of its keys and lets the
129
+ * extractors return `undefined` for the columns the row did not carry. So the
130
+ * input is `T` with every key required and `undefined` allowed, and the return
131
+ * is `T`, where the absences this function creates are the only ones. Requiring
132
+ * the keys is the useful half: a field left out of a literal is a compile error
133
+ * rather than a column that silently never imports.
134
+ */
135
+ type Supplied<T> = { [K in keyof T]-?: T[K] | undefined }
136
+
137
+ function present<T extends object>(draft: Supplied<T>): T {
125
138
  return Object.fromEntries(Object.entries(draft).filter(([, value]) => value !== undefined)) as T
126
139
  }
127
140
 
@@ -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
+ )
@@ -41,10 +41,21 @@ export async function findWorkspace(db: Queryable, id: string): Promise<Workspac
41
41
  return found
42
42
  }
43
43
 
44
+ /**
45
+ * A PATCH's changes, as they arrive: every key optional, and a key may be
46
+ * present holding `undefined` because that is what a parsed request body looks
47
+ * like. `Partial<T>` alone permits only the absence, not the `undefined`.
48
+ *
49
+ * Passing `undefined` through is safe rather than merely tolerated. Drizzle's
50
+ * `mapUpdateSet` filters those entries out before building the `set` clause, so
51
+ * a field the caller did not send is left alone instead of being written null.
52
+ */
53
+ type Changes<T> = { [K in keyof T]?: T[K] | undefined }
54
+
44
55
  export async function updateWorkspace(
45
56
  db: Queryable,
46
57
  id: string,
47
- changes: Partial<typeof workspaces.$inferInsert>,
58
+ changes: Changes<typeof workspaces.$inferInsert>,
48
59
  ): Promise<WorkspaceRecord | undefined> {
49
60
  const [updated] = await db.update(workspaces).set(changes).where(eq(workspaces.id, id)).returning()
50
61
 
@@ -83,12 +83,22 @@ export interface CreateWorkspaceInput {
83
83
  readonly timezone: string
84
84
  }
85
85
 
86
+ /**
87
+ * A partial update, on `api.md`'s rule: an absent field is not being changed,
88
+ * and `null` clears one.
89
+ *
90
+ * Every field spells out `| undefined` because a caller reaches this with the
91
+ * parsed request body, and Zod produces a key holding `undefined` rather than no
92
+ * key at all. Under `exactOptionalPropertyTypes` those are different types, and
93
+ * they are not different behaviour here: `update` spreads this into Drizzle's
94
+ * `set`, which skips undefined values.
95
+ */
86
96
  export interface UpdateWorkspaceInput {
87
- readonly name?: string
88
- readonly slug?: string
89
- readonly timezone?: string
90
- readonly tagline?: string | null
91
- readonly oneLiner?: string | null
97
+ readonly name?: string | undefined
98
+ readonly slug?: string | undefined
99
+ readonly timezone?: string | undefined
100
+ readonly tagline?: string | null | undefined
101
+ readonly oneLiner?: string | null | undefined
92
102
  }
93
103
 
94
104
  export interface WorkspaceService {