@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
|
@@ -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,
|
|
@@ -128,7 +128,23 @@ export function createIdempotencyMiddleware(dependencies: IdempotencyMiddlewareD
|
|
|
128
128
|
return
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
const actor = await resolveActorFrom(dependencies.credentials, context)
|
|
131
|
+
const actor = await resolveActorFrom(dependencies.credentials, context).catch((error: unknown) => {
|
|
132
|
+
if (error instanceof AppError && error.code === 'unauthorized') {
|
|
133
|
+
// No credential to scope a key to. The unauthenticated auth endpoints
|
|
134
|
+
// (`POST /v1/auth/login`, `/signup`, the reset pair) are the case: they
|
|
135
|
+
// must answer without a credential, so a key on one of them cannot 401
|
|
136
|
+
// here. They handle their own auth; idempotency does not apply.
|
|
137
|
+
return undefined
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
throw error
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
if (actor === undefined) {
|
|
144
|
+
await next()
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
|
|
132
148
|
const workspaceId = actorWorkspaceId(actor)
|
|
133
149
|
|
|
134
150
|
// Between signup and a first workspace (`POST /v1/workspaces`, `POST
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { appUrlConfigSchema } from '../../lib/appUrl.ts'
|
|
1
2
|
import type { KelpieModule } from '../../runtime/module.ts'
|
|
2
3
|
import { parseModuleCapability } from '../../runtime/moduleConfig.ts'
|
|
3
4
|
import { SEATS_LIMIT } from './capabilities.ts'
|
|
@@ -20,6 +21,8 @@ export function createWorkspaceModule(migrationsDirectory: string): KelpieModule
|
|
|
20
21
|
structural: true,
|
|
21
22
|
|
|
22
23
|
register(context) {
|
|
24
|
+
const config = context.config(appUrlConfigSchema)
|
|
25
|
+
|
|
23
26
|
context.entitlements.declare(SEATS_LIMIT)
|
|
24
27
|
|
|
25
28
|
// Answers `module.<id>` for whatever a config override left undecided
|
|
@@ -44,6 +47,7 @@ export function createWorkspaceModule(migrationsDirectory: string): KelpieModule
|
|
|
44
47
|
createId: context.createId,
|
|
45
48
|
now: context.now,
|
|
46
49
|
entitlements: context.entitlements,
|
|
50
|
+
appBaseUrl: config.APP_BASE_URL,
|
|
47
51
|
toggleableModuleIds: context.moduleCatalog
|
|
48
52
|
.filter((entry) => !entry.structural)
|
|
49
53
|
.map((entry) => entry.id),
|
|
@@ -132,21 +132,28 @@ export async function deleteMember(db: Queryable, id: string): Promise<void> {
|
|
|
132
132
|
*/
|
|
133
133
|
export async function countMemberReferences(
|
|
134
134
|
db: Queryable,
|
|
135
|
+
workspaceId: string,
|
|
135
136
|
memberId: string,
|
|
136
137
|
): Promise<readonly { readonly type: string; readonly count: number }[]> {
|
|
137
138
|
const sources = [
|
|
138
|
-
{ type: 'deal', table: deals, column: deals.ownerId },
|
|
139
|
-
{ type: 'opportunity', table: opportunities, column: opportunities.ownerId },
|
|
140
|
-
{ type: 'partnership', table: partnerships, column: partnerships.ownerId },
|
|
141
|
-
{ type: 'raise', table: raises, column: raises.ownerId },
|
|
142
|
-
{ type: 'plan_item', table: planItems, column: planItems.ownerId },
|
|
143
|
-
{ type: 'decision', table: decisions, column: decisions.ownerId },
|
|
144
|
-
{ type: 'note', table: notes, column: notes.authorId },
|
|
139
|
+
{ type: 'deal', table: deals, column: deals.ownerId, workspaceId: deals.workspaceId },
|
|
140
|
+
{ type: 'opportunity', table: opportunities, column: opportunities.ownerId, workspaceId: opportunities.workspaceId },
|
|
141
|
+
{ type: 'partnership', table: partnerships, column: partnerships.ownerId, workspaceId: partnerships.workspaceId },
|
|
142
|
+
{ type: 'raise', table: raises, column: raises.ownerId, workspaceId: raises.workspaceId },
|
|
143
|
+
{ type: 'plan_item', table: planItems, column: planItems.ownerId, workspaceId: planItems.workspaceId },
|
|
144
|
+
{ type: 'decision', table: decisions, column: decisions.ownerId, workspaceId: decisions.workspaceId },
|
|
145
|
+
{ type: 'note', table: notes, column: notes.authorId, workspaceId: notes.workspaceId },
|
|
145
146
|
] as const
|
|
146
147
|
|
|
147
148
|
const counted = await Promise.all(
|
|
148
149
|
sources.map(async (source) => {
|
|
149
|
-
|
|
150
|
+
// A member id is globally unique, so the workspace term is defence in
|
|
151
|
+
// depth rather than the thing that scopes the count. It keeps the query
|
|
152
|
+
// safe on its own instead of by that invariant.
|
|
153
|
+
const [row] = await db
|
|
154
|
+
.select({ total: count() })
|
|
155
|
+
.from(source.table)
|
|
156
|
+
.where(and(eq(source.column, memberId), eq(source.workspaceId, workspaceId)))
|
|
150
157
|
|
|
151
158
|
return { type: source.type, count: row?.total ?? 0 }
|
|
152
159
|
}),
|
|
@@ -34,11 +34,6 @@ export const updateBody = z
|
|
|
34
34
|
export const inviteBody = z.object({
|
|
35
35
|
email: z.string().min(1),
|
|
36
36
|
role: z.enum(INVITABLE_ROLES),
|
|
37
|
-
invite_url_template: z.string().min(1).includes('{token}'),
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
export const resendBody = z.object({
|
|
41
|
-
invite_url_template: z.string().min(1).includes('{token}'),
|
|
42
37
|
})
|
|
43
38
|
|
|
44
39
|
export const memberRoleBody = z.object({ role: z.enum(MEMBER_ROLES) })
|
|
@@ -187,7 +182,6 @@ export function mountWorkspaceRoutes(router: Hono, dependencies: WorkspaceRoutes
|
|
|
187
182
|
context.req.param('id'),
|
|
188
183
|
body.email,
|
|
189
184
|
body.role,
|
|
190
|
-
body.invite_url_template,
|
|
191
185
|
)
|
|
192
186
|
|
|
193
187
|
return context.json(inviteResponse(invite), 201)
|
|
@@ -203,12 +197,10 @@ export function mountWorkspaceRoutes(router: Hono, dependencies: WorkspaceRoutes
|
|
|
203
197
|
})
|
|
204
198
|
|
|
205
199
|
router.post('/workspaces/:id/invites/:inviteId/resend', async (context) => {
|
|
206
|
-
const body = await readBody(context, resendBody)
|
|
207
200
|
const invite = await dependencies.service.resendInvite(
|
|
208
201
|
await requireActor(context),
|
|
209
202
|
context.req.param('id'),
|
|
210
203
|
context.req.param('inviteId'),
|
|
211
|
-
body.invite_url_template,
|
|
212
204
|
)
|
|
213
205
|
|
|
214
206
|
return context.json(inviteResponse(invite))
|