@kelpie/server 0.4.0 → 0.5.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 (182) hide show
  1. package/dist/app.d.ts.map +1 -1
  2. package/dist/app.js +14 -0
  3. package/dist/app.js.map +1 -1
  4. package/dist/index.d.ts +2 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +1 -0
  7. package/dist/index.js.map +1 -1
  8. package/dist/lib/appUrl.d.ts +27 -0
  9. package/dist/lib/appUrl.d.ts.map +1 -0
  10. package/dist/lib/appUrl.js +53 -0
  11. package/dist/lib/appUrl.js.map +1 -0
  12. package/dist/lib/clientIp.d.ts +24 -0
  13. package/dist/lib/clientIp.d.ts.map +1 -0
  14. package/dist/lib/clientIp.js +34 -0
  15. package/dist/lib/clientIp.js.map +1 -0
  16. package/dist/lib/config.d.ts +11 -0
  17. package/dist/lib/config.d.ts.map +1 -1
  18. package/dist/lib/config.js +23 -12
  19. package/dist/lib/config.js.map +1 -1
  20. package/dist/lib/egress.d.ts +44 -0
  21. package/dist/lib/egress.d.ts.map +1 -0
  22. package/dist/lib/egress.js +111 -0
  23. package/dist/lib/egress.js.map +1 -0
  24. package/dist/lib/email.d.ts +53 -9
  25. package/dist/lib/email.d.ts.map +1 -1
  26. package/dist/lib/email.js +49 -4
  27. package/dist/lib/email.js.map +1 -1
  28. package/dist/lib/ids.d.ts +2 -0
  29. package/dist/lib/ids.d.ts.map +1 -1
  30. package/dist/lib/ids.js +2 -0
  31. package/dist/lib/ids.js.map +1 -1
  32. package/dist/lib/rateLimit.d.ts +12 -4
  33. package/dist/lib/rateLimit.d.ts.map +1 -1
  34. package/dist/lib/rateLimit.js +6 -0
  35. package/dist/lib/rateLimit.js.map +1 -1
  36. package/dist/modules/agent-tasks/dispatch.d.ts +4 -2
  37. package/dist/modules/agent-tasks/dispatch.d.ts.map +1 -1
  38. package/dist/modules/agent-tasks/dispatch.js +4 -2
  39. package/dist/modules/agent-tasks/dispatch.js.map +1 -1
  40. package/dist/modules/agent-tasks/index.d.ts.map +1 -1
  41. package/dist/modules/agent-tasks/index.js +3 -1
  42. package/dist/modules/agent-tasks/index.js.map +1 -1
  43. package/dist/modules/auth/index.d.ts.map +1 -1
  44. package/dist/modules/auth/index.js +10 -3
  45. package/dist/modules/auth/index.js.map +1 -1
  46. package/dist/modules/auth/repository.d.ts +13 -1
  47. package/dist/modules/auth/repository.d.ts.map +1 -1
  48. package/dist/modules/auth/repository.js +32 -1
  49. package/dist/modules/auth/repository.js.map +1 -1
  50. package/dist/modules/auth/routes.d.ts.map +1 -1
  51. package/dist/modules/auth/routes.js +29 -6
  52. package/dist/modules/auth/routes.js.map +1 -1
  53. package/dist/modules/auth/schema.d.ts +130 -0
  54. package/dist/modules/auth/schema.d.ts.map +1 -1
  55. package/dist/modules/auth/schema.js +16 -0
  56. package/dist/modules/auth/schema.js.map +1 -1
  57. package/dist/modules/auth/service.d.ts +7 -1
  58. package/dist/modules/auth/service.d.ts.map +1 -1
  59. package/dist/modules/auth/service.js +53 -4
  60. package/dist/modules/auth/service.js.map +1 -1
  61. package/dist/modules/deals/repository.d.ts.map +1 -1
  62. package/dist/modules/deals/repository.js +1 -0
  63. package/dist/modules/deals/repository.js.map +1 -1
  64. package/dist/modules/decisions/repository.d.ts.map +1 -1
  65. package/dist/modules/decisions/repository.js +8 -7
  66. package/dist/modules/decisions/repository.js.map +1 -1
  67. package/dist/modules/forms/index.d.ts.map +1 -1
  68. package/dist/modules/forms/index.js +6 -1
  69. package/dist/modules/forms/index.js.map +1 -1
  70. package/dist/modules/forms/publicRoutes.d.ts +3 -0
  71. package/dist/modules/forms/publicRoutes.d.ts.map +1 -1
  72. package/dist/modules/forms/publicRoutes.js +10 -4
  73. package/dist/modules/forms/publicRoutes.js.map +1 -1
  74. package/dist/modules/forms/submission.d.ts +3 -0
  75. package/dist/modules/forms/submission.d.ts.map +1 -1
  76. package/dist/modules/forms/submission.js +6 -0
  77. package/dist/modules/forms/submission.js.map +1 -1
  78. package/dist/modules/opportunities/repository.d.ts.map +1 -1
  79. package/dist/modules/opportunities/repository.js +1 -0
  80. package/dist/modules/opportunities/repository.js.map +1 -1
  81. package/dist/modules/partnerships/repository.d.ts.map +1 -1
  82. package/dist/modules/partnerships/repository.js +1 -0
  83. package/dist/modules/partnerships/repository.js.map +1 -1
  84. package/dist/modules/raises/repository.d.ts.map +1 -1
  85. package/dist/modules/raises/repository.js +1 -0
  86. package/dist/modules/raises/repository.js.map +1 -1
  87. package/dist/modules/rate-limit/middleware.d.ts +4 -4
  88. package/dist/modules/rate-limit/middleware.d.ts.map +1 -1
  89. package/dist/modules/rate-limit/middleware.js +57 -11
  90. package/dist/modules/rate-limit/middleware.js.map +1 -1
  91. package/dist/modules/webhooks/delivery.d.ts +5 -2
  92. package/dist/modules/webhooks/delivery.d.ts.map +1 -1
  93. package/dist/modules/webhooks/delivery.js +5 -2
  94. package/dist/modules/webhooks/delivery.js.map +1 -1
  95. package/dist/modules/webhooks/index.d.ts.map +1 -1
  96. package/dist/modules/webhooks/index.js +3 -1
  97. package/dist/modules/webhooks/index.js.map +1 -1
  98. package/dist/modules/workspace/idempotencyMiddleware.d.ts.map +1 -1
  99. package/dist/modules/workspace/idempotencyMiddleware.js +14 -1
  100. package/dist/modules/workspace/idempotencyMiddleware.js.map +1 -1
  101. package/dist/modules/workspace/index.d.ts.map +1 -1
  102. package/dist/modules/workspace/index.js +3 -0
  103. package/dist/modules/workspace/index.js.map +1 -1
  104. package/dist/modules/workspace/repository.d.ts +1 -1
  105. package/dist/modules/workspace/repository.d.ts.map +1 -1
  106. package/dist/modules/workspace/repository.js +15 -9
  107. package/dist/modules/workspace/repository.js.map +1 -1
  108. package/dist/modules/workspace/routes.d.ts +0 -4
  109. package/dist/modules/workspace/routes.d.ts.map +1 -1
  110. package/dist/modules/workspace/routes.js +2 -7
  111. package/dist/modules/workspace/routes.js.map +1 -1
  112. package/dist/modules/workspace/service.d.ts +4 -2
  113. package/dist/modules/workspace/service.d.ts.map +1 -1
  114. package/dist/modules/workspace/service.js +42 -17
  115. package/dist/modules/workspace/service.js.map +1 -1
  116. package/dist/modules/workspace/tools.d.ts.map +1 -1
  117. package/dist/modules/workspace/tools.js +6 -7
  118. package/dist/modules/workspace/tools.js.map +1 -1
  119. package/dist/operator.d.ts +40 -0
  120. package/dist/operator.d.ts.map +1 -0
  121. package/dist/operator.js +50 -0
  122. package/dist/operator.js.map +1 -0
  123. package/dist/runtime/module.d.ts +25 -1
  124. package/dist/runtime/module.d.ts.map +1 -1
  125. package/dist/runtime/registry.d.ts +16 -1
  126. package/dist/runtime/registry.d.ts.map +1 -1
  127. package/dist/runtime/registry.js +34 -0
  128. package/dist/runtime/registry.js.map +1 -1
  129. package/dist/testing/client.d.ts +3 -1
  130. package/dist/testing/client.d.ts.map +1 -1
  131. package/dist/testing/client.js +19 -2
  132. package/dist/testing/client.js.map +1 -1
  133. package/dist/testing/environment.d.ts +2 -0
  134. package/dist/testing/environment.d.ts.map +1 -1
  135. package/dist/testing/environment.js +3 -0
  136. package/dist/testing/environment.js.map +1 -1
  137. package/dist/webBundle.d.ts +8 -0
  138. package/dist/webBundle.d.ts.map +1 -1
  139. package/dist/webBundle.js +9 -7
  140. package/dist/webBundle.js.map +1 -1
  141. package/migrations/0018_hard_mystique.sql +12 -0
  142. package/migrations/meta/0018_snapshot.json +5113 -0
  143. package/migrations/meta/_journal.json +8 -1
  144. package/package.json +4 -2
  145. package/src/app.ts +16 -0
  146. package/src/index.ts +9 -1
  147. package/src/lib/appUrl.ts +64 -0
  148. package/src/lib/clientIp.ts +41 -0
  149. package/src/lib/config.ts +30 -12
  150. package/src/lib/egress.ts +149 -0
  151. package/src/lib/email.ts +75 -7
  152. package/src/lib/ids.ts +2 -0
  153. package/src/lib/rateLimit.ts +16 -4
  154. package/src/modules/agent-tasks/dispatch.ts +9 -2
  155. package/src/modules/agent-tasks/index.ts +3 -1
  156. package/src/modules/auth/index.ts +12 -5
  157. package/src/modules/auth/repository.ts +56 -1
  158. package/src/modules/auth/routes.ts +34 -6
  159. package/src/modules/auth/schema.ts +17 -0
  160. package/src/modules/auth/service.ts +78 -5
  161. package/src/modules/deals/repository.ts +1 -0
  162. package/src/modules/decisions/repository.ts +8 -7
  163. package/src/modules/forms/index.ts +6 -1
  164. package/src/modules/forms/publicRoutes.ts +14 -4
  165. package/src/modules/forms/submission.ts +10 -0
  166. package/src/modules/opportunities/repository.ts +1 -0
  167. package/src/modules/partnerships/repository.ts +1 -0
  168. package/src/modules/raises/repository.ts +1 -0
  169. package/src/modules/rate-limit/middleware.ts +74 -11
  170. package/src/modules/webhooks/delivery.ts +10 -2
  171. package/src/modules/webhooks/index.ts +3 -1
  172. package/src/modules/workspace/idempotencyMiddleware.ts +17 -1
  173. package/src/modules/workspace/index.ts +4 -0
  174. package/src/modules/workspace/repository.ts +15 -8
  175. package/src/modules/workspace/routes.ts +0 -8
  176. package/src/modules/workspace/service.ts +53 -20
  177. package/src/modules/workspace/tools.ts +7 -21
  178. package/src/runtime/module.ts +25 -1
  179. package/src/runtime/registry.ts +59 -1
  180. package/src/testing/client.ts +22 -2
  181. package/src/testing/environment.ts +4 -0
  182. package/src/webBundle.ts +21 -7
@@ -127,6 +127,13 @@
127
127
  "when": 1786490413323,
128
128
  "tag": "0017_drop_integration_connections",
129
129
  "breakpoints": true
130
+ },
131
+ {
132
+ "idx": 18,
133
+ "version": "7",
134
+ "when": 1786656430307,
135
+ "tag": "0018_hard_mystique",
136
+ "breakpoints": true
130
137
  }
131
138
  ]
132
- }
139
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kelpie/server",
3
- "version": "0.4.0",
3
+ "version": "0.5.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",
@@ -52,16 +52,18 @@
52
52
  },
53
53
  "dependencies": {
54
54
  "@hono/node-server": "^2.0.12",
55
- "@kelpie/schemas": "^0.4.0",
55
+ "@kelpie/schemas": "^0.5.0",
56
56
  "@node-rs/argon2": "^2.0.2",
57
57
  "drizzle-orm": "^0.45.2",
58
58
  "hono": "^4.12.32",
59
+ "nodemailer": "^9.0.5",
59
60
  "postgres": "^3.4.9",
60
61
  "ulid": "^3.0.2",
61
62
  "zod": "^4.4.3"
62
63
  },
63
64
  "devDependencies": {
64
65
  "@types/node": "^26.1.2",
66
+ "@types/nodemailer": "^8.0.1",
65
67
  "drizzle-kit": "^0.31.10",
66
68
  "vite": "^8.2.0",
67
69
  "vitest": "^4.1.10"
package/src/app.ts CHANGED
@@ -172,6 +172,22 @@ export function createApp(dependencies: AppDependencies): Hono<AppBindings> {
172
172
  app.route(MCP_ROUTE_PREFIX, mcp.transport)
173
173
  app.route('/v1', mcp.catalog)
174
174
 
175
+ // Module declarations on the app itself, outside /v1 (`runtime/module.ts`).
176
+ // Middleware first, all of it, then routes: within one request Hono
177
+ // composes matching handlers in registration order, so this is what makes
178
+ // a declared pattern cover matching routes from every module, whichever
179
+ // registered first. Registered after core's own surfaces above, so nothing
180
+ // here can run ahead of them. Not rate limited: the `/v1` budgets are
181
+ // per-workspace-credential, and a declared surface owns its own access
182
+ // rules.
183
+ for (const middleware of dependencies.contributions.appMiddleware) {
184
+ app.use(middleware.pattern, middleware.handler)
185
+ }
186
+
187
+ for (const route of dependencies.contributions.appRoutes) {
188
+ app.on(route.method, route.path, route.handler)
189
+ }
190
+
175
191
  app.onError((error, context) => {
176
192
  if (error instanceof AppError) {
177
193
  return context.json(toErrorBody(error), error.status)
package/src/index.ts CHANGED
@@ -7,6 +7,8 @@ export type { WebBundleOptions } from './webBundle.ts'
7
7
  export { ConfigurationError, loadConfig } from './lib/config.ts'
8
8
  export type { Environment, KelpieConfig, LogLevel, RuntimeMode } from './lib/config.ts'
9
9
 
10
+ export { resolveClientIpFrom } from './lib/clientIp.ts'
11
+
10
12
  export { ModuleConfigFileError, readModuleConfigFile } from './lib/moduleConfig.ts'
11
13
 
12
14
  export {
@@ -105,7 +107,13 @@ export { KEY_KINDS, kindOfSecret, mintKey, parseKeyKind, readBearerToken } from
105
107
  export type { KeyKind, MintedKey } from './modules/api-keys/keys.ts'
106
108
 
107
109
  export { registerModules } from './runtime/registry.ts'
108
- export type { ModuleContributions, ModuleRouter, ModuleRuntimeOptions } from './runtime/registry.ts'
110
+ export type {
111
+ AppMiddlewareContribution,
112
+ AppRouteContribution,
113
+ ModuleContributions,
114
+ ModuleRouter,
115
+ ModuleRuntimeOptions,
116
+ } from './runtime/registry.ts'
109
117
  export type { ModuleServices } from './runtime/module.ts'
110
118
 
111
119
  export type { Actor, ApiKeyActor, SessionActor } from './modules/auth/actor.ts'
@@ -0,0 +1,64 @@
1
+ import { z } from 'zod'
2
+
3
+ /**
4
+ * The deployment's own base URL, and how token links in transactional mail are
5
+ * built from it.
6
+ *
7
+ * The service builds every reset, verification, and invite link itself, from a
8
+ * configured base URL. It does not build them from a URL the caller sends. A
9
+ * caller-supplied link lets anyone point a real Kelpie email at any address,
10
+ * which is an account-takeover path. The base URL is set once per deployment,
11
+ * beside the database URL and the mail provider.
12
+ */
13
+
14
+ const APP_BASE_URL_MESSAGE = 'must be an absolute http:// or https:// URL, e.g. https://crm.example.com'
15
+
16
+ /** True when the value is an absolute http(s) URL with no embedded credentials. */
17
+ function isValidBaseUrl(value: string): boolean {
18
+ let url: URL
19
+
20
+ try {
21
+ url = new URL(value)
22
+ } catch {
23
+ return false
24
+ }
25
+
26
+ const protocolAllowed = url.protocol === 'http:' || url.protocol === 'https:'
27
+
28
+ return protocolAllowed && url.username.length === 0 && url.password.length === 0
29
+ }
30
+
31
+ /** The one variable the auth and workspace modules read to build their links. */
32
+ export const appUrlConfigSchema = z.object({
33
+ APP_BASE_URL: z.string().refine(isValidBaseUrl, { message: APP_BASE_URL_MESSAGE }),
34
+ })
35
+
36
+ export type AppUrlConfig = z.infer<typeof appUrlConfigSchema>
37
+
38
+ /**
39
+ * The browser app's routes for each token-carrying link. They match the paths
40
+ * the single-page app serves (`packages/ui`), so a link built here reaches a
41
+ * real page once that bundle is deployed at `APP_BASE_URL`.
42
+ */
43
+ export const APP_LINK_PATHS = {
44
+ passwordReset: '/reset-password',
45
+ verifyEmail: '/verify-email',
46
+ inviteAccept: '/join',
47
+ } as const
48
+
49
+ /**
50
+ * Builds a link at the deployment's base URL, carrying the token as a query
51
+ * parameter.
52
+ *
53
+ * `new URL(path, baseUrl)` resolves the path against the base, so a base with a
54
+ * trailing slash and one without give the same result. The token is base64url
55
+ * and needs no escaping. `searchParams` encodes it regardless, rather than trust
56
+ * that.
57
+ */
58
+ export function buildAppLink(baseUrl: string, path: string, token: string): string {
59
+ const url = new URL(path, baseUrl)
60
+
61
+ url.searchParams.set('token', token)
62
+
63
+ return url.toString()
64
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Resolving the caller's IP when the service sits behind trusted proxies.
3
+ *
4
+ * The rate limiter keys on this, so trusting it wrongly matters both ways: read a
5
+ * forgeable header and an attacker rotates it to dodge the limit; ignore a real
6
+ * proxy and every caller collapses into one bucket behind the load balancer.
7
+ *
8
+ * The deployment states how many proxies stand in front of it
9
+ * (`TRUSTED_PROXY_HOP_COUNT`). Zero, the default, means the service is reached
10
+ * directly and the socket address is the client. A positive count means the last
11
+ * N entries of `X-Forwarded-For` were written by infrastructure this deployment
12
+ * controls, so the entry just left of them is the client as the outermost trusted
13
+ * proxy saw it, which no earlier hop could forge.
14
+ */
15
+
16
+ /**
17
+ * @param socketAddress The address the TCP connection arrived from.
18
+ * @param forwardedFor The raw `X-Forwarded-For` header, or undefined.
19
+ * @param hopCount How many trusted proxies stand in front of the service.
20
+ * @returns The client's IP. Falls back to the socket address whenever the header
21
+ * is absent or holds fewer entries than the configured hop count, so a
22
+ * misconfiguration over-limits rather than trusts a forgeable position.
23
+ */
24
+ export function resolveClientIpFrom(
25
+ socketAddress: string,
26
+ forwardedFor: string | undefined,
27
+ hopCount: number,
28
+ ): string {
29
+ if (hopCount <= 0 || forwardedFor === undefined) {
30
+ return socketAddress
31
+ }
32
+
33
+ const entries = forwardedFor
34
+ .split(',')
35
+ .map((entry) => entry.trim())
36
+ .filter((entry) => entry.length > 0)
37
+
38
+ const index = entries.length - hopCount
39
+
40
+ return index >= 0 ? (entries[index] ?? socketAddress) : socketAddress
41
+ }
package/src/lib/config.ts CHANGED
@@ -38,6 +38,12 @@ export interface KelpieConfig {
38
38
  */
39
39
  readonly webBundleDirectory: string | undefined
40
40
  readonly rateLimit: RateLimitConfig
41
+ /**
42
+ * How many trusted proxies stand in front of the service. Zero means it is
43
+ * reached directly and the socket address is the client. Positive means the
44
+ * client IP is read from `X-Forwarded-For` (`lib/clientIp.ts`).
45
+ */
46
+ readonly trustedProxyHopCount: number
41
47
  }
42
48
 
43
49
  /** Thrown at boot when the environment cannot produce a valid configuration. */
@@ -69,31 +75,43 @@ const environmentSchema = z.object({
69
75
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']),
70
76
  KELPIE_MODULE_CONFIG_PATH: z.string().min(1).optional(),
71
77
  WEB_BUNDLE_DIR: z.string().min(1).optional(),
72
- ...emailConfigSchema.shape,
78
+ TRUSTED_PROXY_HOP_COUNT: z.coerce.number().int().nonnegative().default(0),
73
79
  ...rateLimitConfigSchema.shape,
74
80
  })
75
81
 
76
82
  /**
77
83
  * Parses an environment into a validated config.
78
84
  *
85
+ * `emailConfigSchema` is a discriminated union, so it is parsed separately
86
+ * from the rest: a union has no flat `.shape` to spread into `environmentSchema`,
87
+ * only its own `EMAIL_PROVIDER`-keyed branches. Problems from both parses are
88
+ * combined into one error, preserving the "every missing variable at once" rule.
89
+ *
79
90
  * @param environment Raw variables, normally `process.env`.
80
91
  * @throws ConfigurationError listing every invalid or missing variable.
81
92
  */
82
93
  export function loadConfig(environment: Environment): KelpieConfig {
83
- const result = environmentSchema.safeParse(environment)
94
+ const environmentResult = environmentSchema.safeParse(environment)
95
+ const emailResult = emailConfigSchema.safeParse(environment)
96
+
97
+ if (!environmentResult.success || !emailResult.success) {
98
+ const problems = [
99
+ ...(environmentResult.success ? [] : environmentResult.error.issues.map(describeValidationIssue)),
100
+ ...(emailResult.success ? [] : emailResult.error.issues.map(describeValidationIssue)),
101
+ ]
84
102
 
85
- if (!result.success) {
86
- throw new ConfigurationError(result.error.issues.map(describeValidationIssue))
103
+ throw new ConfigurationError(problems)
87
104
  }
88
105
 
89
106
  return {
90
- runtimeMode: result.data.NODE_ENV,
91
- port: result.data.PORT,
92
- databaseUrl: result.data.DATABASE_URL,
93
- logLevel: result.data.LOG_LEVEL,
94
- email: { EMAIL_PROVIDER: result.data.EMAIL_PROVIDER, EMAIL_FROM: result.data.EMAIL_FROM },
95
- moduleConfigPath: result.data.KELPIE_MODULE_CONFIG_PATH,
96
- webBundleDirectory: result.data.WEB_BUNDLE_DIR,
97
- rateLimit: rateLimitConfigFrom(result.data),
107
+ runtimeMode: environmentResult.data.NODE_ENV,
108
+ port: environmentResult.data.PORT,
109
+ databaseUrl: environmentResult.data.DATABASE_URL,
110
+ logLevel: environmentResult.data.LOG_LEVEL,
111
+ email: emailResult.data,
112
+ moduleConfigPath: environmentResult.data.KELPIE_MODULE_CONFIG_PATH,
113
+ webBundleDirectory: environmentResult.data.WEB_BUNDLE_DIR,
114
+ rateLimit: rateLimitConfigFrom(environmentResult.data),
115
+ trustedProxyHopCount: environmentResult.data.TRUSTED_PROXY_HOP_COUNT,
98
116
  }
99
117
  }
@@ -0,0 +1,149 @@
1
+ import { lookup } from 'node:dns/promises'
2
+ import { isIP } from 'node:net'
3
+ import { z } from 'zod'
4
+
5
+ /**
6
+ * An optional guard against outbound POSTs to private or reserved addresses.
7
+ *
8
+ * Webhook and agent endpoints are customer-supplied, and a self-hosted Kelpie
9
+ * legitimately posts to an internal host such as `http://automation.internal`.
10
+ * So the guard is off by default (`lib/url.ts` says the same). A hosted
11
+ * deployment, where a customer must not be able to aim a delivery at the
12
+ * metadata service or a neighbour's database, sets `BLOCK_PRIVATE_EGRESS=true`.
13
+ *
14
+ * The check resolves the host and refuses the request when any resolved address
15
+ * is loopback, private, link-local, unique-local, or otherwise reserved. It runs
16
+ * just before the fetch. That leaves a narrow rebinding window between this
17
+ * lookup and the connection's own; the senders already pass `redirect: 'manual'`,
18
+ * which closes the redirect-based bypass, and a hosted deployment pairs this with
19
+ * network-level egress rules. It is defence in depth, not the only defence.
20
+ */
21
+
22
+ export const egressConfigSchema = z.object({
23
+ BLOCK_PRIVATE_EGRESS: z
24
+ .enum(['true', 'false'])
25
+ .default('false')
26
+ .transform((value) => value === 'true'),
27
+ })
28
+
29
+ export type EgressConfig = z.infer<typeof egressConfigSchema>
30
+
31
+ /** Thrown when a target resolves to an address the guard refuses. */
32
+ export class BlockedEgressError extends Error {
33
+ constructor(host: string, address: string) {
34
+ super(`Refusing to connect to ${host}: ${address} is a private or reserved address`)
35
+ this.name = 'BlockedEgressError'
36
+ }
37
+ }
38
+
39
+ /** True for an IPv4 address in a loopback, private, link-local, or reserved range. */
40
+ function isPrivateIpv4(ip: string): boolean {
41
+ const parts = ip.split('.').map((part) => Number(part))
42
+
43
+ if (parts.length !== 4 || parts.some((part) => Number.isNaN(part) || part < 0 || part > 255)) {
44
+ return false
45
+ }
46
+
47
+ const [a, b] = parts as [number, number, number, number]
48
+
49
+ if (a === 0 || a === 10 || a === 127) {
50
+ return true
51
+ }
52
+
53
+ if (a === 169 && b === 254) {
54
+ // Link-local, which is where the cloud metadata service (169.254.169.254) lives.
55
+ return true
56
+ }
57
+
58
+ if (a === 172 && b >= 16 && b <= 31) {
59
+ return true
60
+ }
61
+
62
+ if (a === 192 && b === 168) {
63
+ return true
64
+ }
65
+
66
+ if (a === 100 && b >= 64 && b <= 127) {
67
+ // Carrier-grade NAT.
68
+ return true
69
+ }
70
+
71
+ // Multicast (224.0.0.0/4) and reserved (240.0.0.0/4), including broadcast.
72
+ return a >= 224
73
+ }
74
+
75
+ /** True for an IPv6 address in a loopback, unspecified, link-local, or unique-local range. */
76
+ function isPrivateIpv6(ip: string): boolean {
77
+ const lower = ip.toLowerCase()
78
+
79
+ if (lower === '::1' || lower === '::') {
80
+ return true
81
+ }
82
+
83
+ const mapped = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/u.exec(lower)
84
+
85
+ if (mapped?.[1] !== undefined) {
86
+ // An IPv4 address written in IPv6 form resolves by its IPv4 rules.
87
+ return isPrivateIpv4(mapped[1])
88
+ }
89
+
90
+ const firstHextet = Number.parseInt(lower.split(':')[0] || '0', 16)
91
+
92
+ // fe80::/10 link-local.
93
+ if ((firstHextet & 0xffc0) === 0xfe80) {
94
+ return true
95
+ }
96
+
97
+ // fc00::/7 unique-local.
98
+ return (firstHextet & 0xfe00) === 0xfc00
99
+ }
100
+
101
+ function isPrivateAddress(ip: string): boolean {
102
+ return isIP(ip) === 6 ? isPrivateIpv6(ip) : isPrivateIpv4(ip)
103
+ }
104
+
105
+ export interface EgressGuard {
106
+ /**
107
+ * @throws BlockedEgressError when the URL's host resolves to a private or
108
+ * reserved address. Resolves normally when the guard is off, or when every
109
+ * resolved address is public.
110
+ */
111
+ check(url: string): Promise<void>
112
+ }
113
+
114
+ /**
115
+ * Builds the guard. When `BLOCK_PRIVATE_EGRESS` is false the guard is a no-op, so
116
+ * a self-hosted deployment keeps reaching its internal endpoints.
117
+ *
118
+ * @param resolveHost Injected so a test can classify a host without real DNS.
119
+ */
120
+ export function createEgressGuard(
121
+ config: EgressConfig,
122
+ resolveHost: (host: string) => Promise<readonly string[]> = defaultResolveHost,
123
+ ): EgressGuard {
124
+ if (!config.BLOCK_PRIVATE_EGRESS) {
125
+ return { check: () => Promise.resolve() }
126
+ }
127
+
128
+ return {
129
+ async check(url) {
130
+ const host = new URL(url).hostname
131
+ // A bracketed IPv6 literal arrives with its brackets; `URL.hostname` keeps
132
+ // them, and `isIP` wants them gone.
133
+ const bareHost = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host
134
+ const addresses = isIP(bareHost) !== 0 ? [bareHost] : await resolveHost(bareHost)
135
+
136
+ for (const address of addresses) {
137
+ if (isPrivateAddress(address)) {
138
+ throw new BlockedEgressError(host, address)
139
+ }
140
+ }
141
+ },
142
+ }
143
+ }
144
+
145
+ async function defaultResolveHost(host: string): Promise<readonly string[]> {
146
+ const results = await lookup(host, { all: true })
147
+
148
+ return results.map((result) => result.address)
149
+ }
package/src/lib/email.ts CHANGED
@@ -1,5 +1,7 @@
1
+ import nodemailer from 'nodemailer'
1
2
  import { z } from 'zod'
2
3
 
4
+ import { describeThrown } from './errors.ts'
3
5
  import type { Logger } from './logger.ts'
4
6
 
5
7
  /**
@@ -7,9 +9,15 @@ import type { Logger } from './logger.ts'
7
9
  * and account-change notifications, nothing else. Kelpie never sends outreach
8
10
  * email.
9
11
  *
10
- * Roadmap decision 4: the provider is configured, never hardcoded. Core ships the
11
- * port and the `log` provider. Real providers ship as modules, so the open-source
12
- * assembly has no vendor account baked into it.
12
+ * Roadmap decision 4: the provider is configured, never hardcoded. Core ships
13
+ * the port and two providers, `log` and `smtp`. Both need no vendor account a
14
+ * deployment doesn't already have, which is what keeps them in core rather than
15
+ * a module, per `modules.md`'s split test. A provider that does need one
16
+ * (Resend, Postmark, Mailtrap, SendGrid) is a commercial integration under that
17
+ * same test and belongs in a module: it supplies its own `EmailSender` from its
18
+ * own config, and an assembly's entry point wires it into `services.email` in
19
+ * place of `createEmailSender`, the same way `kelpie-cloud/src/server.ts` wires
20
+ * this one today.
13
21
  */
14
22
 
15
23
  export interface EmailMessage {
@@ -22,12 +30,28 @@ export interface EmailSender {
22
30
  send(message: EmailMessage): Promise<void>
23
31
  }
24
32
 
25
- export const emailConfigSchema = z.object({
26
- EMAIL_PROVIDER: z.enum(['log']),
33
+ const logEmailConfigSchema = z.object({
34
+ EMAIL_PROVIDER: z.literal('log'),
27
35
  EMAIL_FROM: z.string().min(1),
28
36
  })
29
37
 
38
+ const smtpEmailConfigSchema = z.object({
39
+ EMAIL_PROVIDER: z.literal('smtp'),
40
+ EMAIL_FROM: z.string().min(1),
41
+ SMTP_HOST: z.string().min(1),
42
+ SMTP_PORT: z.coerce.number().int().positive().max(65535),
43
+ SMTP_SECURE: z.enum(['true', 'false']).transform((value) => value === 'true'),
44
+ SMTP_USER: z.string().min(1),
45
+ SMTP_PASSWORD: z.string().min(1),
46
+ })
47
+
48
+ export const emailConfigSchema = z.discriminatedUnion('EMAIL_PROVIDER', [
49
+ logEmailConfigSchema,
50
+ smtpEmailConfigSchema,
51
+ ])
52
+
30
53
  export type EmailConfig = z.infer<typeof emailConfigSchema>
54
+ export type SmtpEmailConfig = z.infer<typeof smtpEmailConfigSchema>
31
55
 
32
56
  /**
33
57
  * Writes the message to the log instead of sending it. For self-hosted
@@ -51,15 +75,59 @@ export function createLogEmailSender(logger: Logger, from: string): EmailSender
51
75
  }
52
76
  }
53
77
 
78
+ /**
79
+ * What `createSmtpEmailSender` sends a message through. A real deployment gets
80
+ * one backed by `nodemailer`; a test injects one that records calls, so the
81
+ * sender is verifiable without a container running a real SMTP conversation.
82
+ */
83
+ export interface SmtpTransport {
84
+ sendMail(message: { from: string; to: string; subject: string; text: string }): Promise<unknown>
85
+ }
86
+
87
+ function createNodemailerTransport(config: SmtpEmailConfig): SmtpTransport {
88
+ return nodemailer.createTransport({
89
+ host: config.SMTP_HOST,
90
+ port: config.SMTP_PORT,
91
+ secure: config.SMTP_SECURE,
92
+ auth: { user: config.SMTP_USER, pass: config.SMTP_PASSWORD },
93
+ })
94
+ }
95
+
96
+ /** Sends over SMTP. `transport` defaults to a real connection built from `config`. */
97
+ export function createSmtpEmailSender(
98
+ config: SmtpEmailConfig,
99
+ logger: Logger,
100
+ transport: SmtpTransport = createNodemailerTransport(config),
101
+ ): EmailSender {
102
+ return {
103
+ async send(message) {
104
+ try {
105
+ await transport.sendMail({
106
+ from: config.EMAIL_FROM,
107
+ to: message.to,
108
+ subject: message.subject,
109
+ text: message.body,
110
+ })
111
+ } catch (cause) {
112
+ const reason = describeThrown(cause)
113
+ logger.error('smtp send failed', { to: message.to, reason })
114
+ throw new Error(`Failed to send email to ${message.to} over SMTP: ${reason}`, { cause })
115
+ }
116
+ },
117
+ }
118
+ }
119
+
54
120
  /**
55
121
  * Builds the configured sender.
56
122
  *
57
- * @throws Never. An unknown provider cannot reach here: the config schema is an
58
- * enum, so boot rejects it first.
123
+ * @throws Never. An unknown provider cannot reach here: the config schema is a
124
+ * discriminated union, so boot rejects it first.
59
125
  */
60
126
  export function createEmailSender(config: EmailConfig, logger: Logger): EmailSender {
61
127
  switch (config.EMAIL_PROVIDER) {
62
128
  case 'log':
63
129
  return createLogEmailSender(logger, config.EMAIL_FROM)
130
+ case 'smtp':
131
+ return createSmtpEmailSender(config, logger)
64
132
  }
65
133
  }
package/src/lib/ids.ts CHANGED
@@ -9,6 +9,8 @@ export const idPrefixes = {
9
9
  user: 'usr',
10
10
  session: 'ses',
11
11
  passwordResetToken: 'pwr',
12
+ /** Never returned over the wire either, for the same reason as `passwordResetToken`. */
13
+ emailVerificationToken: 'evt',
12
14
  teamMember: 'mem',
13
15
  invite: 'inv',
14
16
  apiKey: 'key',
@@ -1,10 +1,10 @@
1
1
  import { z } from 'zod'
2
2
 
3
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.
4
+ * Budgets for the request classes `api.md` rate-limits: public form submissions,
5
+ * unauthenticated auth endpoints by IP, login by account, and everything else
6
+ * under an API key. Roadmap Phase 6: self-host packaging calls for this with
7
+ * nothing to configure, so every variable here is optional and defaulted.
8
8
  */
9
9
 
10
10
  export interface RateLimitBudget {
@@ -15,6 +15,12 @@ export interface RateLimitBudget {
15
15
  export interface RateLimitConfig {
16
16
  readonly forms: RateLimitBudget
17
17
  readonly auth: RateLimitBudget
18
+ /**
19
+ * Login attempts for one email address, whatever IP they come from. The `auth`
20
+ * budget alone caps one IP, so a botnet spread across many addresses could
21
+ * still grind one account. This is the per-account half of that defence.
22
+ */
23
+ readonly loginAccount: RateLimitBudget
18
24
  readonly api: RateLimitBudget
19
25
  }
20
26
 
@@ -23,6 +29,8 @@ export const rateLimitConfigSchema = z.object({
23
29
  RATE_LIMIT_FORMS_WINDOW_SECONDS: z.coerce.number().int().positive().default(60),
24
30
  RATE_LIMIT_AUTH_LIMIT: z.coerce.number().int().positive().default(10),
25
31
  RATE_LIMIT_AUTH_WINDOW_SECONDS: z.coerce.number().int().positive().default(60),
32
+ RATE_LIMIT_LOGIN_ACCOUNT_LIMIT: z.coerce.number().int().positive().default(10),
33
+ RATE_LIMIT_LOGIN_ACCOUNT_WINDOW_SECONDS: z.coerce.number().int().positive().default(900),
26
34
  RATE_LIMIT_API_LIMIT: z.coerce.number().int().positive().default(600),
27
35
  RATE_LIMIT_API_WINDOW_SECONDS: z.coerce.number().int().positive().default(60),
28
36
  })
@@ -37,6 +45,10 @@ export function rateLimitConfigFrom(parsed: z.infer<typeof rateLimitConfigSchema
37
45
  limit: parsed.RATE_LIMIT_AUTH_LIMIT,
38
46
  windowMs: parsed.RATE_LIMIT_AUTH_WINDOW_SECONDS * 1000,
39
47
  },
48
+ loginAccount: {
49
+ limit: parsed.RATE_LIMIT_LOGIN_ACCOUNT_LIMIT,
50
+ windowMs: parsed.RATE_LIMIT_LOGIN_ACCOUNT_WINDOW_SECONDS * 1000,
51
+ },
40
52
  api: {
41
53
  limit: parsed.RATE_LIMIT_API_LIMIT,
42
54
  windowMs: parsed.RATE_LIMIT_API_WINDOW_SECONDS * 1000,
@@ -1,4 +1,5 @@
1
1
  import type { Database } from '../../lib/database.ts'
2
+ import type { EgressGuard } from '../../lib/egress.ts'
2
3
  import { describeThrown } from '../../lib/errors.ts'
3
4
  import type { Logger } from '../../lib/logger.ts'
4
5
  import { SecretDecryptionError } from '../../lib/secrets.ts'
@@ -49,11 +50,17 @@ export type SendDispatch = (request: DispatchRequest) => Promise<DispatchOutcome
49
50
  * The catch is broad because this is the process boundary: `fetch` rejects
50
51
  * with anything from a DNS failure to an abort, and turning all of it into one
51
52
  * outcome is the port's whole job. Nothing is swallowed — the reason lands on
52
- * the run.
53
+ * the run. A blocked private address (`egress.check`) reaches the same catch
54
+ * and lands on the run the same way.
53
55
  */
54
- export function createHttpSender(fetchImplementation: typeof fetch = fetch): SendDispatch {
56
+ export function createHttpSender(
57
+ egress: EgressGuard,
58
+ fetchImplementation: typeof fetch = fetch,
59
+ ): SendDispatch {
55
60
  return async (request) => {
56
61
  try {
62
+ await egress.check(request.url)
63
+
57
64
  const response = await fetchImplementation(request.url, {
58
65
  method: 'POST',
59
66
  body: request.body,
@@ -1,3 +1,4 @@
1
+ import { createEgressGuard, egressConfigSchema } from '../../lib/egress.ts'
1
2
  import { createSecretCipher, secretEncryptionConfigSchema } from '../../lib/secrets.ts'
2
3
  import type { KelpieModule } from '../../runtime/module.ts'
3
4
  import { createDispatchEngine, createHttpSender } from './dispatch.ts'
@@ -48,12 +49,13 @@ export function createAgentTasksModule(
48
49
  // key means no stored auth header can ever be read back, and finding that
49
50
  // out when a dispatch quietly fails is far too late.
50
51
  const cipher = createSecretCipher(context.config(secretEncryptionConfigSchema))
52
+ const egress = createEgressGuard(context.config(egressConfigSchema))
51
53
 
52
54
  const engine = createDispatchEngine({
53
55
  db: context.db,
54
56
  now: context.now,
55
57
  cipher,
56
- send: options.send ?? createHttpSender(),
58
+ send: options.send ?? createHttpSender(egress),
57
59
  log: context.log,
58
60
  })
59
61