@kelpie/server 0.4.1 → 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.
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +14 -0
- package/dist/app.js.map +1 -1
- package/dist/index.d.ts +2 -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/appUrl.d.ts +27 -0
- package/dist/lib/appUrl.d.ts.map +1 -0
- package/dist/lib/appUrl.js +53 -0
- package/dist/lib/appUrl.js.map +1 -0
- package/dist/lib/clientIp.d.ts +24 -0
- package/dist/lib/clientIp.d.ts.map +1 -0
- package/dist/lib/clientIp.js +34 -0
- package/dist/lib/clientIp.js.map +1 -0
- package/dist/lib/config.d.ts +6 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +2 -0
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/egress.d.ts +44 -0
- package/dist/lib/egress.d.ts.map +1 -0
- package/dist/lib/egress.js +111 -0
- package/dist/lib/egress.js.map +1 -0
- package/dist/lib/ids.d.ts +2 -0
- package/dist/lib/ids.d.ts.map +1 -1
- package/dist/lib/ids.js +2 -0
- package/dist/lib/ids.js.map +1 -1
- package/dist/lib/rateLimit.d.ts +12 -4
- package/dist/lib/rateLimit.d.ts.map +1 -1
- package/dist/lib/rateLimit.js +6 -0
- package/dist/lib/rateLimit.js.map +1 -1
- package/dist/modules/agent-tasks/dispatch.d.ts +4 -2
- package/dist/modules/agent-tasks/dispatch.d.ts.map +1 -1
- package/dist/modules/agent-tasks/dispatch.js +4 -2
- package/dist/modules/agent-tasks/dispatch.js.map +1 -1
- package/dist/modules/agent-tasks/index.d.ts.map +1 -1
- package/dist/modules/agent-tasks/index.js +3 -1
- package/dist/modules/agent-tasks/index.js.map +1 -1
- package/dist/modules/auth/index.d.ts.map +1 -1
- package/dist/modules/auth/index.js +10 -3
- package/dist/modules/auth/index.js.map +1 -1
- package/dist/modules/auth/repository.d.ts +13 -1
- package/dist/modules/auth/repository.d.ts.map +1 -1
- package/dist/modules/auth/repository.js +32 -1
- package/dist/modules/auth/repository.js.map +1 -1
- package/dist/modules/auth/routes.d.ts.map +1 -1
- package/dist/modules/auth/routes.js +29 -6
- package/dist/modules/auth/routes.js.map +1 -1
- package/dist/modules/auth/schema.d.ts +130 -0
- package/dist/modules/auth/schema.d.ts.map +1 -1
- package/dist/modules/auth/schema.js +16 -0
- package/dist/modules/auth/schema.js.map +1 -1
- package/dist/modules/auth/service.d.ts +7 -1
- package/dist/modules/auth/service.d.ts.map +1 -1
- package/dist/modules/auth/service.js +53 -4
- package/dist/modules/auth/service.js.map +1 -1
- package/dist/modules/deals/repository.d.ts.map +1 -1
- package/dist/modules/deals/repository.js +1 -0
- package/dist/modules/deals/repository.js.map +1 -1
- package/dist/modules/decisions/repository.d.ts.map +1 -1
- package/dist/modules/decisions/repository.js +8 -7
- package/dist/modules/decisions/repository.js.map +1 -1
- package/dist/modules/forms/index.d.ts.map +1 -1
- package/dist/modules/forms/index.js +6 -1
- package/dist/modules/forms/index.js.map +1 -1
- package/dist/modules/forms/publicRoutes.d.ts +3 -0
- package/dist/modules/forms/publicRoutes.d.ts.map +1 -1
- package/dist/modules/forms/publicRoutes.js +10 -4
- package/dist/modules/forms/publicRoutes.js.map +1 -1
- package/dist/modules/forms/submission.d.ts +3 -0
- package/dist/modules/forms/submission.d.ts.map +1 -1
- package/dist/modules/forms/submission.js +6 -0
- package/dist/modules/forms/submission.js.map +1 -1
- package/dist/modules/opportunities/repository.d.ts.map +1 -1
- package/dist/modules/opportunities/repository.js +1 -0
- package/dist/modules/opportunities/repository.js.map +1 -1
- package/dist/modules/partnerships/repository.d.ts.map +1 -1
- package/dist/modules/partnerships/repository.js +1 -0
- package/dist/modules/partnerships/repository.js.map +1 -1
- package/dist/modules/raises/repository.d.ts.map +1 -1
- package/dist/modules/raises/repository.js +1 -0
- package/dist/modules/raises/repository.js.map +1 -1
- package/dist/modules/rate-limit/middleware.d.ts +4 -4
- package/dist/modules/rate-limit/middleware.d.ts.map +1 -1
- package/dist/modules/rate-limit/middleware.js +57 -11
- package/dist/modules/rate-limit/middleware.js.map +1 -1
- package/dist/modules/webhooks/delivery.d.ts +5 -2
- package/dist/modules/webhooks/delivery.d.ts.map +1 -1
- package/dist/modules/webhooks/delivery.js +5 -2
- package/dist/modules/webhooks/delivery.js.map +1 -1
- package/dist/modules/webhooks/index.d.ts.map +1 -1
- package/dist/modules/webhooks/index.js +3 -1
- package/dist/modules/webhooks/index.js.map +1 -1
- package/dist/modules/workspace/idempotencyMiddleware.d.ts.map +1 -1
- package/dist/modules/workspace/idempotencyMiddleware.js +14 -1
- package/dist/modules/workspace/idempotencyMiddleware.js.map +1 -1
- package/dist/modules/workspace/index.d.ts.map +1 -1
- package/dist/modules/workspace/index.js +3 -0
- package/dist/modules/workspace/index.js.map +1 -1
- package/dist/modules/workspace/repository.d.ts +1 -1
- package/dist/modules/workspace/repository.d.ts.map +1 -1
- package/dist/modules/workspace/repository.js +15 -9
- package/dist/modules/workspace/repository.js.map +1 -1
- package/dist/modules/workspace/routes.d.ts +0 -4
- package/dist/modules/workspace/routes.d.ts.map +1 -1
- package/dist/modules/workspace/routes.js +2 -7
- package/dist/modules/workspace/routes.js.map +1 -1
- package/dist/modules/workspace/service.d.ts +4 -2
- package/dist/modules/workspace/service.d.ts.map +1 -1
- package/dist/modules/workspace/service.js +42 -17
- package/dist/modules/workspace/service.js.map +1 -1
- package/dist/modules/workspace/tools.d.ts.map +1 -1
- package/dist/modules/workspace/tools.js +6 -7
- package/dist/modules/workspace/tools.js.map +1 -1
- package/dist/operator.d.ts +40 -0
- package/dist/operator.d.ts.map +1 -0
- package/dist/operator.js +50 -0
- package/dist/operator.js.map +1 -0
- package/dist/runtime/module.d.ts +25 -1
- package/dist/runtime/module.d.ts.map +1 -1
- package/dist/runtime/registry.d.ts +16 -1
- package/dist/runtime/registry.d.ts.map +1 -1
- package/dist/runtime/registry.js +34 -0
- package/dist/runtime/registry.js.map +1 -1
- package/dist/testing/client.d.ts +3 -1
- package/dist/testing/client.d.ts.map +1 -1
- package/dist/testing/client.js +19 -2
- package/dist/testing/client.js.map +1 -1
- package/dist/testing/environment.d.ts +2 -0
- package/dist/testing/environment.d.ts.map +1 -1
- package/dist/testing/environment.js +3 -0
- package/dist/testing/environment.js.map +1 -1
- package/dist/webBundle.d.ts +8 -0
- package/dist/webBundle.d.ts.map +1 -1
- package/dist/webBundle.js +9 -7
- package/dist/webBundle.js.map +1 -1
- package/migrations/0018_hard_mystique.sql +12 -0
- package/migrations/meta/0018_snapshot.json +5113 -0
- package/migrations/meta/_journal.json +8 -1
- package/package.json +2 -2
- package/src/app.ts +16 -0
- package/src/index.ts +9 -1
- package/src/lib/appUrl.ts +64 -0
- package/src/lib/clientIp.ts +41 -0
- package/src/lib/config.ts +8 -0
- package/src/lib/egress.ts +149 -0
- package/src/lib/ids.ts +2 -0
- package/src/lib/rateLimit.ts +16 -4
- package/src/modules/agent-tasks/dispatch.ts +9 -2
- package/src/modules/agent-tasks/index.ts +3 -1
- package/src/modules/auth/index.ts +12 -5
- package/src/modules/auth/repository.ts +56 -1
- package/src/modules/auth/routes.ts +34 -6
- package/src/modules/auth/schema.ts +17 -0
- package/src/modules/auth/service.ts +78 -5
- package/src/modules/deals/repository.ts +1 -0
- package/src/modules/decisions/repository.ts +8 -7
- package/src/modules/forms/index.ts +6 -1
- package/src/modules/forms/publicRoutes.ts +14 -4
- package/src/modules/forms/submission.ts +10 -0
- package/src/modules/opportunities/repository.ts +1 -0
- package/src/modules/partnerships/repository.ts +1 -0
- package/src/modules/raises/repository.ts +1 -0
- package/src/modules/rate-limit/middleware.ts +74 -11
- package/src/modules/webhooks/delivery.ts +10 -2
- package/src/modules/webhooks/index.ts +3 -1
- package/src/modules/workspace/idempotencyMiddleware.ts +17 -1
- package/src/modules/workspace/index.ts +4 -0
- package/src/modules/workspace/repository.ts +15 -8
- package/src/modules/workspace/routes.ts +0 -8
- package/src/modules/workspace/service.ts +53 -20
- package/src/modules/workspace/tools.ts +7 -21
- package/src/runtime/module.ts +25 -1
- package/src/runtime/registry.ts +59 -1
- package/src/testing/client.ts +22 -2
- package/src/testing/environment.ts +4 -0
- 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.
|
|
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,7 +52,7 @@
|
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
54
|
"@hono/node-server": "^2.0.12",
|
|
55
|
-
"@kelpie/schemas": "^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",
|
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 {
|
|
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,6 +75,7 @@ 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(),
|
|
78
|
+
TRUSTED_PROXY_HOP_COUNT: z.coerce.number().int().nonnegative().default(0),
|
|
72
79
|
...rateLimitConfigSchema.shape,
|
|
73
80
|
})
|
|
74
81
|
|
|
@@ -105,5 +112,6 @@ export function loadConfig(environment: Environment): KelpieConfig {
|
|
|
105
112
|
moduleConfigPath: environmentResult.data.KELPIE_MODULE_CONFIG_PATH,
|
|
106
113
|
webBundleDirectory: environmentResult.data.WEB_BUNDLE_DIR,
|
|
107
114
|
rateLimit: rateLimitConfigFrom(environmentResult.data),
|
|
115
|
+
trustedProxyHopCount: environmentResult.data.TRUSTED_PROXY_HOP_COUNT,
|
|
108
116
|
}
|
|
109
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/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',
|
package/src/lib/rateLimit.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Budgets for the
|
|
5
|
-
*
|
|
6
|
-
* API key. Roadmap Phase 6: self-host packaging calls for this with
|
|
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(
|
|
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
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
|
|
3
|
+
import { appUrlConfigSchema } from '../../lib/appUrl.ts'
|
|
3
4
|
import type { KelpieModule } from '../../runtime/module.ts'
|
|
4
5
|
import { mountAuthRoutes } from './routes.ts'
|
|
5
6
|
import * as schema from './schema.ts'
|
|
@@ -12,10 +13,12 @@ import { createAuthService } from './service.ts'
|
|
|
12
13
|
* `users`.
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
|
-
const authConfigSchema = z
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
const authConfigSchema = z
|
|
17
|
+
.object({
|
|
18
|
+
/** Cookies go out `Secure` everywhere except development. */
|
|
19
|
+
NODE_ENV: z.enum(['development', 'test', 'production']),
|
|
20
|
+
})
|
|
21
|
+
.merge(appUrlConfigSchema)
|
|
19
22
|
|
|
20
23
|
export function createAuthModule(migrationsDirectory: string): KelpieModule {
|
|
21
24
|
return {
|
|
@@ -30,6 +33,7 @@ export function createAuthModule(migrationsDirectory: string): KelpieModule {
|
|
|
30
33
|
email: context.email,
|
|
31
34
|
createId: context.createId,
|
|
32
35
|
now: context.now,
|
|
36
|
+
appBaseUrl: config.APP_BASE_URL,
|
|
33
37
|
})
|
|
34
38
|
|
|
35
39
|
context.schema(schema, migrationsDirectory)
|
|
@@ -39,7 +43,10 @@ export function createAuthModule(migrationsDirectory: string): KelpieModule {
|
|
|
39
43
|
db: context.db,
|
|
40
44
|
now: context.now,
|
|
41
45
|
service,
|
|
42
|
-
|
|
46
|
+
// `Secure` everywhere except development. A test host reaches the API
|
|
47
|
+
// over http through a test client, which stores the cookie regardless
|
|
48
|
+
// of the flag, so test is not excluded.
|
|
49
|
+
cookie: { secure: config.NODE_ENV !== 'development' },
|
|
43
50
|
})
|
|
44
51
|
})
|
|
45
52
|
|
|
@@ -3,7 +3,13 @@ import { and, desc, eq, gt, isNull, ne } from 'drizzle-orm'
|
|
|
3
3
|
import type { Database } from '../../lib/database.ts'
|
|
4
4
|
import type { Transaction } from '../../runtime/transaction.ts'
|
|
5
5
|
import { workspaceMembers } from '../workspace/schema.ts'
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
emailVerificationTokens,
|
|
8
|
+
passwordResetTokens,
|
|
9
|
+
sessions,
|
|
10
|
+
userPreferences,
|
|
11
|
+
users,
|
|
12
|
+
} from './schema.ts'
|
|
7
13
|
|
|
8
14
|
/**
|
|
9
15
|
* Drizzle queries for accounts and sessions. No business logic: the service
|
|
@@ -209,6 +215,55 @@ export async function markPasswordResetTokenUsed(db: Queryable, id: string, now:
|
|
|
209
215
|
.where(eq(passwordResetTokens.id, id))
|
|
210
216
|
}
|
|
211
217
|
|
|
218
|
+
export async function insertEmailVerificationToken(
|
|
219
|
+
db: Queryable,
|
|
220
|
+
values: typeof emailVerificationTokens.$inferInsert,
|
|
221
|
+
): Promise<void> {
|
|
222
|
+
await db.insert(emailVerificationTokens).values(values)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Unused and unexpired. A used or stale token is not a token. */
|
|
226
|
+
export async function findUsableEmailVerificationToken(
|
|
227
|
+
db: Queryable,
|
|
228
|
+
tokenHash: string,
|
|
229
|
+
now: Date,
|
|
230
|
+
): Promise<typeof emailVerificationTokens.$inferSelect | undefined> {
|
|
231
|
+
const [found] = await db
|
|
232
|
+
.select()
|
|
233
|
+
.from(emailVerificationTokens)
|
|
234
|
+
.where(
|
|
235
|
+
and(
|
|
236
|
+
eq(emailVerificationTokens.tokenHash, tokenHash),
|
|
237
|
+
isNull(emailVerificationTokens.usedAt),
|
|
238
|
+
gt(emailVerificationTokens.expiresAt, now),
|
|
239
|
+
),
|
|
240
|
+
)
|
|
241
|
+
.limit(1)
|
|
242
|
+
|
|
243
|
+
return found
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export async function markEmailVerificationTokenUsed(db: Queryable, id: string, now: Date): Promise<void> {
|
|
247
|
+
await db
|
|
248
|
+
.update(emailVerificationTokens)
|
|
249
|
+
.set({ usedAt: now })
|
|
250
|
+
.where(eq(emailVerificationTokens.id, id))
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Sets `email_verified_at` if it is not already set.
|
|
255
|
+
*
|
|
256
|
+
* Guarded in the `WHERE` clause rather than read-then-write, so accepting a
|
|
257
|
+
* second invite or confirming a stale token after the account is already
|
|
258
|
+
* verified is a no-op instead of overwriting the original verification time.
|
|
259
|
+
*/
|
|
260
|
+
export async function markEmailVerified(db: Queryable, userId: string, now: Date): Promise<void> {
|
|
261
|
+
await db
|
|
262
|
+
.update(users)
|
|
263
|
+
.set({ emailVerifiedAt: now })
|
|
264
|
+
.where(and(eq(users.id, userId), isNull(users.emailVerifiedAt)))
|
|
265
|
+
}
|
|
266
|
+
|
|
212
267
|
/** The membership a session's active workspace resolves to, if any. */
|
|
213
268
|
export async function findMembership(
|
|
214
269
|
db: Queryable,
|
|
@@ -31,8 +31,6 @@ const logInBody = z.object({
|
|
|
31
31
|
|
|
32
32
|
const resetRequestBody = z.object({
|
|
33
33
|
email: z.string().min(1),
|
|
34
|
-
/** Where the emailed link points. `{token}` is replaced with the reset token. */
|
|
35
|
-
reset_url_template: z.string().min(1).includes('{token}'),
|
|
36
34
|
})
|
|
37
35
|
|
|
38
36
|
const resetConfirmBody = z.object({
|
|
@@ -45,6 +43,10 @@ const changePasswordBody = z.object({
|
|
|
45
43
|
new_password: z.string().min(1),
|
|
46
44
|
})
|
|
47
45
|
|
|
46
|
+
const verifyEmailConfirmBody = z.object({
|
|
47
|
+
token: z.string().min(1),
|
|
48
|
+
})
|
|
49
|
+
|
|
48
50
|
const updateAccountBody = z
|
|
49
51
|
.strictObject({
|
|
50
52
|
name: z.string().min(1),
|
|
@@ -95,13 +97,18 @@ function describeClient(context: Context): { device?: string; location?: string
|
|
|
95
97
|
|
|
96
98
|
function accountResponse(issued: IssuedSession): Record<string, unknown> {
|
|
97
99
|
return {
|
|
98
|
-
account:
|
|
100
|
+
account: accountBody(issued.account),
|
|
99
101
|
active_workspace_id: issued.activeWorkspaceId,
|
|
100
102
|
}
|
|
101
103
|
}
|
|
102
104
|
|
|
103
105
|
function accountBody(account: AccountView): Record<string, unknown> {
|
|
104
|
-
return {
|
|
106
|
+
return {
|
|
107
|
+
id: account.id,
|
|
108
|
+
email: account.email,
|
|
109
|
+
name: account.name,
|
|
110
|
+
email_verified: account.emailVerified,
|
|
111
|
+
}
|
|
105
112
|
}
|
|
106
113
|
|
|
107
114
|
function preferencesResponse(preferences: PreferenceValues): Record<string, unknown> {
|
|
@@ -131,7 +138,12 @@ export function mountAuthRoutes(router: Hono, dependencies: AuthRoutesDependenci
|
|
|
131
138
|
|
|
132
139
|
router.post('/auth/signup', async (context) => {
|
|
133
140
|
const body = await readBody(context, signUpBody)
|
|
134
|
-
const issued = await dependencies.service.signUp({
|
|
141
|
+
const issued = await dependencies.service.signUp({
|
|
142
|
+
email: body.email,
|
|
143
|
+
name: body.name,
|
|
144
|
+
password: body.password,
|
|
145
|
+
...describeClient(context),
|
|
146
|
+
})
|
|
135
147
|
|
|
136
148
|
writeSessionCookie(context, issued.sessionToken, dependencies.cookie)
|
|
137
149
|
|
|
@@ -156,12 +168,14 @@ export function mountAuthRoutes(router: Hono, dependencies: AuthRoutesDependenci
|
|
|
156
168
|
|
|
157
169
|
router.get('/auth/me', async (context) => {
|
|
158
170
|
const actor = await requireActor(context)
|
|
171
|
+
const account = await dependencies.service.getAccount(actor)
|
|
159
172
|
|
|
160
173
|
return context.json({
|
|
161
174
|
user_id: actor.userId,
|
|
162
175
|
session_id: actor.sessionId,
|
|
163
176
|
workspace_id: actor.workspaceId,
|
|
164
177
|
role: actor.role,
|
|
178
|
+
email_verified: account.emailVerified,
|
|
165
179
|
})
|
|
166
180
|
})
|
|
167
181
|
|
|
@@ -233,7 +247,7 @@ export function mountAuthRoutes(router: Hono, dependencies: AuthRoutesDependenci
|
|
|
233
247
|
*/
|
|
234
248
|
router.post('/auth/password-reset', async (context) => {
|
|
235
249
|
const body = await readBody(context, resetRequestBody)
|
|
236
|
-
await dependencies.service.requestPasswordReset(body.email
|
|
250
|
+
await dependencies.service.requestPasswordReset(body.email)
|
|
237
251
|
|
|
238
252
|
return context.body(null, 202)
|
|
239
253
|
})
|
|
@@ -244,4 +258,18 @@ export function mountAuthRoutes(router: Hono, dependencies: AuthRoutesDependenci
|
|
|
244
258
|
|
|
245
259
|
return context.body(null, 204)
|
|
246
260
|
})
|
|
261
|
+
|
|
262
|
+
/** Also what a "resend" button calls: a fresh token each time, and a no-op once verified. */
|
|
263
|
+
router.post('/auth/verify-email', async (context) => {
|
|
264
|
+
await dependencies.service.requestEmailVerification(await requireActor(context))
|
|
265
|
+
|
|
266
|
+
return context.body(null, 202)
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
router.post('/auth/verify-email/confirm', async (context) => {
|
|
270
|
+
const body = await readBody(context, verifyEmailConfirmBody)
|
|
271
|
+
await dependencies.service.confirmEmailVerification(body.token)
|
|
272
|
+
|
|
273
|
+
return context.body(null, 204)
|
|
274
|
+
})
|
|
247
275
|
}
|