@kelpie/server 0.4.1 → 0.5.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.
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +27 -0
- package/dist/app.js.map +1 -1
- package/dist/index.d.ts +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/capabilities.d.ts +10 -1
- package/dist/modules/workspace/capabilities.d.ts.map +1 -1
- package/dist/modules/workspace/capabilities.js +13 -0
- package/dist/modules/workspace/capabilities.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 +5 -1
- 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/modules/workspace/workspaceAccessMiddleware.d.ts +8 -0
- package/dist/modules/workspace/workspaceAccessMiddleware.d.ts.map +1 -0
- package/dist/modules/workspace/workspaceAccessMiddleware.js +58 -0
- package/dist/modules/workspace/workspaceAccessMiddleware.js.map +1 -0
- 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 +32 -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/capabilities.ts +15 -1
- package/src/modules/workspace/idempotencyMiddleware.ts +17 -1
- package/src/modules/workspace/index.ts +6 -1
- 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/modules/workspace/workspaceAccessMiddleware.ts +76 -0
- 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
|
@@ -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
|
}
|
|
@@ -19,6 +19,8 @@ export const users = pgTable('users', {
|
|
|
19
19
|
email: citext('email').notNull().unique(),
|
|
20
20
|
name: text('name').notNull(),
|
|
21
21
|
passwordHash: text('password_hash').notNull(),
|
|
22
|
+
/** Null until the address is verified, either by confirming a token or by accepting a workspace invite. */
|
|
23
|
+
emailVerifiedAt: moment('email_verified_at'),
|
|
22
24
|
createdAt: createdAt(),
|
|
23
25
|
updatedAt: updatedAt(),
|
|
24
26
|
})
|
|
@@ -75,3 +77,18 @@ export const passwordResetTokens = pgTable('password_reset_tokens', {
|
|
|
75
77
|
usedAt: moment('used_at'),
|
|
76
78
|
createdAt: createdAt(),
|
|
77
79
|
})
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* One-shot tokens for the sign-up verification link. Same shape and the same
|
|
83
|
+
* reasoning as `passwordResetTokens`: hashed, marked used rather than deleted.
|
|
84
|
+
*/
|
|
85
|
+
export const emailVerificationTokens = pgTable('email_verification_tokens', {
|
|
86
|
+
id: primaryId(),
|
|
87
|
+
userId: text('user_id')
|
|
88
|
+
.notNull()
|
|
89
|
+
.references(() => users.id, { onDelete: 'cascade' }),
|
|
90
|
+
tokenHash: text('token_hash').notNull().unique(),
|
|
91
|
+
expiresAt: moment('expires_at').notNull(),
|
|
92
|
+
usedAt: moment('used_at'),
|
|
93
|
+
createdAt: createdAt(),
|
|
94
|
+
})
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { APP_LINK_PATHS, buildAppLink } from '../../lib/appUrl.ts'
|
|
1
2
|
import type { EmailSender } from '../../lib/email.ts'
|
|
2
3
|
import { AppError } from '../../lib/errors.ts'
|
|
3
4
|
import type { IdFactory } from '../../lib/ids.ts'
|
|
@@ -22,6 +23,8 @@ import * as repository from './repository.ts'
|
|
|
22
23
|
|
|
23
24
|
const SESSION_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000
|
|
24
25
|
const RESET_TOKEN_LIFETIME_MS = 60 * 60 * 1000
|
|
26
|
+
/** Longer than a reset token: there is no stolen-account urgency, and an inbox is checked on its own time. */
|
|
27
|
+
const EMAIL_VERIFICATION_TOKEN_LIFETIME_MS = 24 * 60 * 60 * 1000
|
|
25
28
|
|
|
26
29
|
export interface AuthDependencies {
|
|
27
30
|
readonly db: Database
|
|
@@ -29,6 +32,8 @@ export interface AuthDependencies {
|
|
|
29
32
|
readonly email: EmailSender
|
|
30
33
|
readonly createId: IdFactory
|
|
31
34
|
readonly now: () => Date
|
|
35
|
+
/** The deployment's own base URL. Every emailed link is built from it. */
|
|
36
|
+
readonly appBaseUrl: string
|
|
32
37
|
/** Injected only so tests can pin tokens. Production uses the crypto default. */
|
|
33
38
|
readonly newToken?: () => string
|
|
34
39
|
}
|
|
@@ -37,6 +42,7 @@ export interface AccountView {
|
|
|
37
42
|
readonly id: string
|
|
38
43
|
readonly email: string
|
|
39
44
|
readonly name: string
|
|
45
|
+
readonly emailVerified: boolean
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
/** A session plus the plaintext token, which exists only in this return value. */
|
|
@@ -79,7 +85,7 @@ export interface LogInInput {
|
|
|
79
85
|
}
|
|
80
86
|
|
|
81
87
|
function toAccountView(user: repository.UserRecord): AccountView {
|
|
82
|
-
return { id: user.id, email: user.email, name: user.name }
|
|
88
|
+
return { id: user.id, email: user.email, name: user.name, emailVerified: user.emailVerifiedAt !== null }
|
|
83
89
|
}
|
|
84
90
|
|
|
85
91
|
function toPreferenceValues(record: repository.UserPreferencesRecord): PreferenceValues {
|
|
@@ -129,9 +135,12 @@ export interface AuthService {
|
|
|
129
135
|
listSessions(actor: SessionActor): Promise<readonly SessionView[]>
|
|
130
136
|
revokeSession(actor: SessionActor, sessionId: string): Promise<void>
|
|
131
137
|
/** Resolves whether or not the address is registered. */
|
|
132
|
-
requestPasswordReset(email: string
|
|
138
|
+
requestPasswordReset(email: string): Promise<void>
|
|
133
139
|
confirmPasswordReset(token: string, newPassword: string): Promise<void>
|
|
134
140
|
changePassword(actor: SessionActor, currentPassword: string, newPassword: string): Promise<void>
|
|
141
|
+
/** Issues a fresh verification token and emails it. A no-op once already verified. */
|
|
142
|
+
requestEmailVerification(actor: SessionActor): Promise<void>
|
|
143
|
+
confirmEmailVerification(token: string): Promise<void>
|
|
135
144
|
}
|
|
136
145
|
|
|
137
146
|
export function createAuthService(dependencies: AuthDependencies): AuthService {
|
|
@@ -181,6 +190,16 @@ export function createAuthService(dependencies: AuthDependencies): AuthService {
|
|
|
181
190
|
}
|
|
182
191
|
}
|
|
183
192
|
|
|
193
|
+
function sendVerificationEmail(to: string, token: string): Promise<void> {
|
|
194
|
+
const link = buildAppLink(dependencies.appBaseUrl, APP_LINK_PATHS.verifyEmail, token)
|
|
195
|
+
|
|
196
|
+
return dependencies.email.send({
|
|
197
|
+
to,
|
|
198
|
+
subject: 'Verify your Kelpie email address',
|
|
199
|
+
body: `Confirm this address to finish setting up your account:\n\n${link}\n\nThe link expires in 24 hours.`,
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
|
|
184
203
|
return {
|
|
185
204
|
/** Creates the account only. The first workspace comes from onboarding. */
|
|
186
205
|
async signUp(input: SignUpInput): Promise<IssuedSession> {
|
|
@@ -192,8 +211,10 @@ export function createAuthService(dependencies: AuthDependencies): AuthService {
|
|
|
192
211
|
|
|
193
212
|
const email = normaliseEmail(input.email)
|
|
194
213
|
const passwordHash = await hashPassword(input.password)
|
|
214
|
+
const now = dependencies.now()
|
|
215
|
+
const verificationToken = newToken()
|
|
195
216
|
|
|
196
|
-
|
|
217
|
+
const issued = await dependencies.transaction(async ({ tx }) => {
|
|
197
218
|
let user: repository.UserRecord
|
|
198
219
|
|
|
199
220
|
try {
|
|
@@ -215,8 +236,20 @@ export function createAuthService(dependencies: AuthDependencies): AuthService {
|
|
|
215
236
|
throw error
|
|
216
237
|
}
|
|
217
238
|
|
|
239
|
+
await repository.insertEmailVerificationToken(tx, {
|
|
240
|
+
id: dependencies.createId('emailVerificationToken'),
|
|
241
|
+
userId: user.id,
|
|
242
|
+
tokenHash: hashToken(verificationToken),
|
|
243
|
+
expiresAt: new Date(now.getTime() + EMAIL_VERIFICATION_TOKEN_LIFETIME_MS),
|
|
244
|
+
})
|
|
245
|
+
|
|
218
246
|
return issueSession(tx, user, input.device, input.location)
|
|
219
247
|
})
|
|
248
|
+
|
|
249
|
+
// Sent after commit, so a rolled-back signup never emails a token.
|
|
250
|
+
await sendVerificationEmail(email, verificationToken)
|
|
251
|
+
|
|
252
|
+
return issued
|
|
220
253
|
},
|
|
221
254
|
|
|
222
255
|
async logIn(input: LogInInput): Promise<IssuedSession> {
|
|
@@ -367,7 +400,7 @@ export function createAuthService(dependencies: AuthDependencies): AuthService {
|
|
|
367
400
|
* Always resolves, whether or not the address is registered. Telling a
|
|
368
401
|
* stranger which addresses have accounts is the whole attack.
|
|
369
402
|
*/
|
|
370
|
-
async requestPasswordReset(email: string
|
|
403
|
+
async requestPasswordReset(email: string): Promise<void> {
|
|
371
404
|
const user = await repository.findUserByEmail(dependencies.db, normaliseEmail(email))
|
|
372
405
|
|
|
373
406
|
if (user === undefined) {
|
|
@@ -384,10 +417,12 @@ export function createAuthService(dependencies: AuthDependencies): AuthService {
|
|
|
384
417
|
expiresAt: new Date(now.getTime() + RESET_TOKEN_LIFETIME_MS),
|
|
385
418
|
})
|
|
386
419
|
|
|
420
|
+
const link = buildAppLink(dependencies.appBaseUrl, APP_LINK_PATHS.passwordReset, token)
|
|
421
|
+
|
|
387
422
|
await dependencies.email.send({
|
|
388
423
|
to: user.email,
|
|
389
424
|
subject: 'Reset your Kelpie password',
|
|
390
|
-
body: `Use this link within the hour to choose a new password:\n\n${
|
|
425
|
+
body: `Use this link within the hour to choose a new password:\n\n${link}\n\nIf you did not ask for this, ignore it.`,
|
|
391
426
|
})
|
|
392
427
|
},
|
|
393
428
|
|
|
@@ -437,5 +472,43 @@ export function createAuthService(dependencies: AuthDependencies): AuthService {
|
|
|
437
472
|
await repository.deleteOtherSessionsForUser(tx, user.id, actor.sessionId)
|
|
438
473
|
})
|
|
439
474
|
},
|
|
475
|
+
|
|
476
|
+
async requestEmailVerification(actor: SessionActor): Promise<void> {
|
|
477
|
+
const user = await requireUser(actor.userId)
|
|
478
|
+
|
|
479
|
+
if (user.emailVerifiedAt !== null) {
|
|
480
|
+
return
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const now = dependencies.now()
|
|
484
|
+
const token = newToken()
|
|
485
|
+
|
|
486
|
+
await repository.insertEmailVerificationToken(dependencies.db, {
|
|
487
|
+
id: dependencies.createId('emailVerificationToken'),
|
|
488
|
+
userId: user.id,
|
|
489
|
+
tokenHash: hashToken(token),
|
|
490
|
+
expiresAt: new Date(now.getTime() + EMAIL_VERIFICATION_TOKEN_LIFETIME_MS),
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
await sendVerificationEmail(user.email, token)
|
|
494
|
+
},
|
|
495
|
+
|
|
496
|
+
async confirmEmailVerification(token: string): Promise<void> {
|
|
497
|
+
const now = dependencies.now()
|
|
498
|
+
const record = await repository.findUsableEmailVerificationToken(
|
|
499
|
+
dependencies.db,
|
|
500
|
+
hashToken(token),
|
|
501
|
+
now,
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
if (record === undefined) {
|
|
505
|
+
throw AppError.unauthorized('That verification link is invalid or has expired')
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
await dependencies.transaction(async ({ tx }) => {
|
|
509
|
+
await repository.markEmailVerificationTokenUsed(tx, record.id, now)
|
|
510
|
+
await repository.markEmailVerified(tx, record.userId, now)
|
|
511
|
+
})
|
|
512
|
+
},
|
|
440
513
|
}
|
|
441
514
|
}
|
|
@@ -49,12 +49,12 @@ export interface DecisionFilters {
|
|
|
49
49
|
* away, so a candidate-targeted decision matches on body and rationale only.
|
|
50
50
|
*/
|
|
51
51
|
const NAMED_TARGET_TABLES = [
|
|
52
|
-
{ table: people, id: people.id, name: people.name },
|
|
53
|
-
{ table: companies, id: companies.id, name: companies.name },
|
|
54
|
-
{ table: deals, id: deals.id, name: deals.name },
|
|
55
|
-
{ table: opportunities, id: opportunities.id, name: opportunities.name },
|
|
56
|
-
{ table: partnerships, id: partnerships.id, name: partnerships.name },
|
|
57
|
-
{ table: raises, id: raises.id, name: raises.name },
|
|
52
|
+
{ table: people, id: people.id, workspaceId: people.workspaceId, name: people.name },
|
|
53
|
+
{ table: companies, id: companies.id, workspaceId: companies.workspaceId, name: companies.name },
|
|
54
|
+
{ table: deals, id: deals.id, workspaceId: deals.workspaceId, name: deals.name },
|
|
55
|
+
{ table: opportunities, id: opportunities.id, workspaceId: opportunities.workspaceId, name: opportunities.name },
|
|
56
|
+
{ table: partnerships, id: partnerships.id, workspaceId: partnerships.workspaceId, name: partnerships.name },
|
|
57
|
+
{ table: raises, id: raises.id, workspaceId: raises.workspaceId, name: raises.name },
|
|
58
58
|
]
|
|
59
59
|
|
|
60
60
|
/**
|
|
@@ -64,10 +64,11 @@ const NAMED_TARGET_TABLES = [
|
|
|
64
64
|
*/
|
|
65
65
|
function targetNameMatches(pattern: string): SQL[] {
|
|
66
66
|
return NAMED_TARGET_TABLES.map(
|
|
67
|
-
({ table, id, name }) => sql`exists (
|
|
67
|
+
({ table, id, workspaceId, name }) => sql`exists (
|
|
68
68
|
select 1
|
|
69
69
|
from ${table}
|
|
70
70
|
where ${id} = ${decisions.targetId}
|
|
71
|
+
and ${workspaceId} = ${decisions.workspaceId}
|
|
71
72
|
and ${name} ilike ${pattern}
|
|
72
73
|
)`,
|
|
73
74
|
)
|
|
@@ -41,6 +41,7 @@ export function createFormsModule(migrationsDirectory: string): KelpieModule {
|
|
|
41
41
|
createId: context.createId,
|
|
42
42
|
now: context.now,
|
|
43
43
|
}),
|
|
44
|
+
entitlements: context.entitlements,
|
|
44
45
|
})
|
|
45
46
|
|
|
46
47
|
context.schema(schema, migrationsDirectory)
|
|
@@ -50,7 +51,11 @@ export function createFormsModule(migrationsDirectory: string): KelpieModule {
|
|
|
50
51
|
})
|
|
51
52
|
|
|
52
53
|
context.publicRoutes((router) => {
|
|
53
|
-
mountPublicFormRoutes(router, {
|
|
54
|
+
mountPublicFormRoutes(router, {
|
|
55
|
+
db: context.db,
|
|
56
|
+
submissions,
|
|
57
|
+
entitlements: context.entitlements,
|
|
58
|
+
})
|
|
54
59
|
})
|
|
55
60
|
|
|
56
61
|
registerFormsTools(context.mcp, service)
|
|
@@ -4,6 +4,9 @@ import { z } from 'zod'
|
|
|
4
4
|
import type { Database } from '../../lib/database.ts'
|
|
5
5
|
import { AppError } from '../../lib/errors.ts'
|
|
6
6
|
import { PUBLIC_ROUTE_PREFIX, readJsonBody, requestOrigin } from '../../lib/http.ts'
|
|
7
|
+
import { requireCapability } from '../../runtime/entitlements.ts'
|
|
8
|
+
import type { EntitlementRegistry } from '../../runtime/entitlements.ts'
|
|
9
|
+
import { moduleCapabilityName } from '../../runtime/moduleConfig.ts'
|
|
7
10
|
import { embedContentSecurityPolicy, renderEmbedPage } from './embed.ts'
|
|
8
11
|
import * as repository from './repository.ts'
|
|
9
12
|
import type { FormSubmitService, SubmitOutcome } from './submission.ts'
|
|
@@ -28,17 +31,20 @@ export interface PublicFormRoutesDependencies {
|
|
|
28
31
|
/** Injected so a test can pin the value the CSP and the tags share. */
|
|
29
32
|
readonly generateNonce?: () => string
|
|
30
33
|
readonly submissions: FormSubmitService
|
|
34
|
+
/** The embed of a form in a workspace that has turned the module off is refused. */
|
|
35
|
+
readonly entitlements: EntitlementRegistry
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
function submitResponse(outcome: SubmitOutcome): Record<string, unknown> {
|
|
39
|
+
// The upserted record ids stay off this response on purpose. The caller is an
|
|
40
|
+
// unauthenticated website, and a Kelpie id is a ULID whose timestamp would tell
|
|
41
|
+
// that caller whether the person or company it named was already in the CRM.
|
|
42
|
+
// The service still computes them for its own events and activities; they are
|
|
43
|
+
// simply not on the wire here.
|
|
34
44
|
return {
|
|
35
45
|
id: outcome.submissionId,
|
|
36
46
|
form_id: outcome.formId,
|
|
37
47
|
submitted_at: outcome.submittedAt.toISOString(),
|
|
38
|
-
person_id: outcome.personId,
|
|
39
|
-
company_id: outcome.companyId,
|
|
40
|
-
position_id: outcome.positionId,
|
|
41
|
-
deal_id: outcome.dealId,
|
|
42
48
|
// Echoed so an embed can render the confirmation without a second request
|
|
43
49
|
// for a form definition it has no other reason to fetch.
|
|
44
50
|
thank_you_message: outcome.thankYouMessage,
|
|
@@ -73,6 +79,10 @@ export function mountPublicFormRoutes(
|
|
|
73
79
|
throw AppError.notFound('Form not found')
|
|
74
80
|
}
|
|
75
81
|
|
|
82
|
+
// Ungated by the runtime, like the submit route: a workspace with the forms
|
|
83
|
+
// module off does not serve its embed either.
|
|
84
|
+
await requireCapability(dependencies.entitlements, form.workspaceId, moduleCapabilityName('forms'))
|
|
85
|
+
|
|
76
86
|
const nonce = generateNonce()
|
|
77
87
|
const page = renderEmbedPage({
|
|
78
88
|
form,
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { Database } from '../../lib/database.ts'
|
|
2
2
|
import { AppError } from '../../lib/errors.ts'
|
|
3
3
|
import type { IdFactory } from '../../lib/ids.ts'
|
|
4
|
+
import { requireCapability } from '../../runtime/entitlements.ts'
|
|
5
|
+
import type { EntitlementRegistry } from '../../runtime/entitlements.ts'
|
|
6
|
+
import { moduleCapabilityName } from '../../runtime/moduleConfig.ts'
|
|
4
7
|
import type { BufferedEvents, Transaction, TransactionScope } from '../../runtime/transaction.ts'
|
|
5
8
|
import type { ActivityRecorder, SystemActor } from '../activities/recorder.ts'
|
|
6
9
|
import { describeCreationVia, describeFormSubmission } from '../activities/wording.ts'
|
|
@@ -85,6 +88,8 @@ export interface SubmissionDependencies {
|
|
|
85
88
|
readonly createId: IdFactory
|
|
86
89
|
readonly now: () => Date
|
|
87
90
|
readonly recordActivity: ActivityRecorder
|
|
91
|
+
/** A submit into a workspace that has turned the forms module off is refused. */
|
|
92
|
+
readonly entitlements: EntitlementRegistry
|
|
88
93
|
}
|
|
89
94
|
|
|
90
95
|
/** What the submit created or matched. Each id is null when the rule did not apply. */
|
|
@@ -394,6 +399,11 @@ export function createFormSubmitService(dependencies: SubmissionDependencies): F
|
|
|
394
399
|
throw AppError.notFound('Form not found')
|
|
395
400
|
}
|
|
396
401
|
|
|
402
|
+
// The public surface is ungated by the runtime (`runtime/registry.ts` gates
|
|
403
|
+
// only credentialled routes), so a workspace that has turned the forms
|
|
404
|
+
// module off is refused here, the same 403 the REST surface gives.
|
|
405
|
+
await requireCapability(dependencies.entitlements, form.workspaceId, moduleCapabilityName('forms'))
|
|
406
|
+
|
|
397
407
|
if (form.status === 'paused') {
|
|
398
408
|
throw AppError.conflict('This form is not accepting submissions')
|
|
399
409
|
}
|
|
@@ -39,10 +39,25 @@ const UNAUTHENTICATED_AUTH_PATHS: ReadonlySet<string> = new Set([
|
|
|
39
39
|
'/v1/auth/login',
|
|
40
40
|
'/v1/auth/password-reset',
|
|
41
41
|
'/v1/auth/password-reset/confirm',
|
|
42
|
+
'/v1/auth/verify-email/confirm',
|
|
42
43
|
])
|
|
43
44
|
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
/** The one path in the set that also carries a per-account budget. */
|
|
46
|
+
const LOGIN_PATH = '/v1/auth/login'
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The public form routes that carry the forms budget: the submit POST and the
|
|
50
|
+
* embed GET. Both do database work for an unauthenticated caller, so both are
|
|
51
|
+
* metered by IP. Every other path under the public prefix passes through.
|
|
52
|
+
*/
|
|
53
|
+
function isMeteredFormRoute(context: Context): boolean {
|
|
54
|
+
const path = context.req.path
|
|
55
|
+
|
|
56
|
+
if (context.req.method === 'POST' && path.endsWith('/submit')) {
|
|
57
|
+
return true
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return context.req.method === 'GET' && path.endsWith('/embed')
|
|
46
61
|
}
|
|
47
62
|
|
|
48
63
|
/** Floors `now` to the start of its fixed window, so every caller in the same window shares one row. */
|
|
@@ -51,12 +66,45 @@ function windowStart(now: Date, windowMs: number): Date {
|
|
|
51
66
|
}
|
|
52
67
|
|
|
53
68
|
/**
|
|
54
|
-
* The largest configured window across
|
|
55
|
-
*
|
|
56
|
-
*
|
|
69
|
+
* The largest configured window across every budget. A bucket cannot be expired
|
|
70
|
+
* before its own window elapses, so this is a safe cutoff for pruning regardless
|
|
71
|
+
* of which budget created the row. The `login-account` window is the longest by
|
|
72
|
+
* default, so leaving it out would prune a live account bucket early.
|
|
57
73
|
*/
|
|
58
74
|
function maxWindowMs(config: RateLimitConfig): number {
|
|
59
|
-
return Math.max(
|
|
75
|
+
return Math.max(
|
|
76
|
+
config.forms.windowMs,
|
|
77
|
+
config.auth.windowMs,
|
|
78
|
+
config.loginAccount.windowMs,
|
|
79
|
+
config.api.windowMs,
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The email a login request names, normalised the way the auth service
|
|
85
|
+
* normalises it (`modules/auth/service.ts`), or undefined when the body carries
|
|
86
|
+
* no usable one.
|
|
87
|
+
*
|
|
88
|
+
* Reads the body through Hono's cache, so the login handler's own read of it
|
|
89
|
+
* later is unaffected. A malformed body resolves to undefined here and is left
|
|
90
|
+
* for the handler to reject.
|
|
91
|
+
*/
|
|
92
|
+
async function readLoginEmail(context: Context): Promise<string | undefined> {
|
|
93
|
+
const body: unknown = await context.req.json().catch(() => undefined)
|
|
94
|
+
|
|
95
|
+
if (typeof body !== 'object' || body === null) {
|
|
96
|
+
return undefined
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const email = (body as Record<string, unknown>).email
|
|
100
|
+
|
|
101
|
+
if (typeof email !== 'string') {
|
|
102
|
+
return undefined
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const normalised = email.trim().toLowerCase()
|
|
106
|
+
|
|
107
|
+
return normalised.length === 0 ? undefined : normalised
|
|
60
108
|
}
|
|
61
109
|
|
|
62
110
|
/**
|
|
@@ -94,17 +142,17 @@ async function enforceBudget(
|
|
|
94
142
|
}
|
|
95
143
|
|
|
96
144
|
/**
|
|
97
|
-
* Guards
|
|
145
|
+
* Guards the public form routes that reach the database, keyed by the caller's
|
|
146
|
+
* IP: the submit `POST` and the embed `GET`.
|
|
98
147
|
*
|
|
99
|
-
* Mount this **after** `PUBLIC_CORS` in `app.ts`.
|
|
100
|
-
*
|
|
101
|
-
* page itself, passes through untouched.
|
|
148
|
+
* Mount this **after** `PUBLIC_CORS` in `app.ts`. Every other path under the
|
|
149
|
+
* public prefix passes through untouched.
|
|
102
150
|
*/
|
|
103
151
|
export function createFormSubmitRateLimitMiddleware(
|
|
104
152
|
dependencies: RateLimitMiddlewareDependencies,
|
|
105
153
|
): MiddlewareHandler {
|
|
106
154
|
return async (context, next) => {
|
|
107
|
-
if (!
|
|
155
|
+
if (!isMeteredFormRoute(context)) {
|
|
108
156
|
await next()
|
|
109
157
|
return
|
|
110
158
|
}
|
|
@@ -146,6 +194,21 @@ export function createAuthAndApiRateLimitMiddleware(
|
|
|
146
194
|
key: dependencies.resolveClientIp(context),
|
|
147
195
|
budget: dependencies.config.auth,
|
|
148
196
|
})
|
|
197
|
+
|
|
198
|
+
// A second budget for login, keyed on the account rather than the IP, so a
|
|
199
|
+
// distributed attack on one address is capped whatever IPs it comes from.
|
|
200
|
+
if (path === LOGIN_PATH) {
|
|
201
|
+
const email = await readLoginEmail(context)
|
|
202
|
+
|
|
203
|
+
if (email !== undefined) {
|
|
204
|
+
await enforceBudget(dependencies, context, {
|
|
205
|
+
scope: 'login-account',
|
|
206
|
+
key: email,
|
|
207
|
+
budget: dependencies.config.loginAccount,
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
149
212
|
await next()
|
|
150
213
|
return
|
|
151
214
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
|
|
3
3
|
import type { Database } from '../../lib/database.ts'
|
|
4
|
+
import type { EgressGuard } from '../../lib/egress.ts'
|
|
4
5
|
import { describeThrown } from '../../lib/errors.ts'
|
|
5
6
|
import type { IdFactory } from '../../lib/ids.ts'
|
|
6
7
|
import type { Logger } from '../../lib/logger.ts'
|
|
@@ -115,11 +116,18 @@ export function sleepFor(milliseconds: number): Promise<void> {
|
|
|
115
116
|
* The catch is broad because this is the process boundary: `fetch` rejects with
|
|
116
117
|
* anything from a DNS failure to an abort, the set is open, and turning all of
|
|
117
118
|
* it into one outcome is the port's whole job. Nothing is swallowed — the
|
|
118
|
-
* reason reaches the log and the delivery is recorded as failed.
|
|
119
|
+
* reason reaches the log and the delivery is recorded as failed. A blocked
|
|
120
|
+
* private address (`egress.check`) reaches the same catch and is recorded the
|
|
121
|
+
* same way, as a failed attempt rather than a crash.
|
|
119
122
|
*/
|
|
120
|
-
export function createHttpSender(
|
|
123
|
+
export function createHttpSender(
|
|
124
|
+
egress: EgressGuard,
|
|
125
|
+
fetchImplementation: typeof fetch = fetch,
|
|
126
|
+
): SendDelivery {
|
|
121
127
|
return async (request) => {
|
|
122
128
|
try {
|
|
129
|
+
await egress.check(request.url)
|
|
130
|
+
|
|
123
131
|
const response = await fetchImplementation(request.url, {
|
|
124
132
|
method: 'POST',
|
|
125
133
|
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 {
|
|
@@ -49,6 +50,7 @@ export function createWebhooksModule(
|
|
|
49
50
|
// should stop boot, not prune by the wrong window.
|
|
50
51
|
const cipher = createSecretCipher(context.config(secretEncryptionConfigSchema))
|
|
51
52
|
const retention = context.config(deliveryRetentionConfigSchema)
|
|
53
|
+
const egress = createEgressGuard(context.config(egressConfigSchema))
|
|
52
54
|
|
|
53
55
|
const service = createWebhooksService({
|
|
54
56
|
db: context.db,
|
|
@@ -64,7 +66,7 @@ export function createWebhooksModule(
|
|
|
64
66
|
createId: context.createId,
|
|
65
67
|
now: context.now,
|
|
66
68
|
cipher,
|
|
67
|
-
send: options.send ?? createHttpSender(),
|
|
69
|
+
send: options.send ?? createHttpSender(egress),
|
|
68
70
|
sleep: options.sleep ?? sleepFor,
|
|
69
71
|
retentionDays: retention.WEBHOOK_DELIVERY_RETENTION_DAYS,
|
|
70
72
|
log: context.log,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { LimitCapability } from '../../runtime/entitlements.ts'
|
|
1
|
+
import type { FlagCapability, LimitCapability } from '../../runtime/entitlements.ts'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Capabilities the workspace module declares.
|
|
@@ -12,3 +12,17 @@ export const SEATS_LIMIT: LimitCapability = {
|
|
|
12
12
|
kind: 'limit',
|
|
13
13
|
description: 'How many people can belong to one workspace. Unlimited in open source.',
|
|
14
14
|
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Whether a workspace's own members may use it at all. Checked once per
|
|
18
|
+
* request by a blanket `/v1` and `/mcp` middleware (`app.ts`), not by any
|
|
19
|
+
* service — this is deliberately coarser than every other capability here.
|
|
20
|
+
* Granted by default: no provider answers it in a self-hosted assembly, so
|
|
21
|
+
* `EntitlementRegistry`'s open-source default (`entitlements.ts`) applies
|
|
22
|
+
* and nothing changes for anyone without a module that supplies one.
|
|
23
|
+
*/
|
|
24
|
+
export const WORKSPACE_ACCESS: FlagCapability = {
|
|
25
|
+
name: 'workspace.access',
|
|
26
|
+
kind: 'flag',
|
|
27
|
+
description: 'Whether this workspace may be used at all. A deployment-wide override, not a plan feature.',
|
|
28
|
+
}
|