@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.
Files changed (187) hide show
  1. package/dist/app.d.ts.map +1 -1
  2. package/dist/app.js +27 -0
  3. package/dist/app.js.map +1 -1
  4. package/dist/index.d.ts +2 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +1 -0
  7. package/dist/index.js.map +1 -1
  8. package/dist/lib/appUrl.d.ts +27 -0
  9. package/dist/lib/appUrl.d.ts.map +1 -0
  10. package/dist/lib/appUrl.js +53 -0
  11. package/dist/lib/appUrl.js.map +1 -0
  12. package/dist/lib/clientIp.d.ts +24 -0
  13. package/dist/lib/clientIp.d.ts.map +1 -0
  14. package/dist/lib/clientIp.js +34 -0
  15. package/dist/lib/clientIp.js.map +1 -0
  16. package/dist/lib/config.d.ts +6 -0
  17. package/dist/lib/config.d.ts.map +1 -1
  18. package/dist/lib/config.js +2 -0
  19. package/dist/lib/config.js.map +1 -1
  20. package/dist/lib/egress.d.ts +44 -0
  21. package/dist/lib/egress.d.ts.map +1 -0
  22. package/dist/lib/egress.js +111 -0
  23. package/dist/lib/egress.js.map +1 -0
  24. package/dist/lib/ids.d.ts +2 -0
  25. package/dist/lib/ids.d.ts.map +1 -1
  26. package/dist/lib/ids.js +2 -0
  27. package/dist/lib/ids.js.map +1 -1
  28. package/dist/lib/rateLimit.d.ts +12 -4
  29. package/dist/lib/rateLimit.d.ts.map +1 -1
  30. package/dist/lib/rateLimit.js +6 -0
  31. package/dist/lib/rateLimit.js.map +1 -1
  32. package/dist/modules/agent-tasks/dispatch.d.ts +4 -2
  33. package/dist/modules/agent-tasks/dispatch.d.ts.map +1 -1
  34. package/dist/modules/agent-tasks/dispatch.js +4 -2
  35. package/dist/modules/agent-tasks/dispatch.js.map +1 -1
  36. package/dist/modules/agent-tasks/index.d.ts.map +1 -1
  37. package/dist/modules/agent-tasks/index.js +3 -1
  38. package/dist/modules/agent-tasks/index.js.map +1 -1
  39. package/dist/modules/auth/index.d.ts.map +1 -1
  40. package/dist/modules/auth/index.js +10 -3
  41. package/dist/modules/auth/index.js.map +1 -1
  42. package/dist/modules/auth/repository.d.ts +13 -1
  43. package/dist/modules/auth/repository.d.ts.map +1 -1
  44. package/dist/modules/auth/repository.js +32 -1
  45. package/dist/modules/auth/repository.js.map +1 -1
  46. package/dist/modules/auth/routes.d.ts.map +1 -1
  47. package/dist/modules/auth/routes.js +29 -6
  48. package/dist/modules/auth/routes.js.map +1 -1
  49. package/dist/modules/auth/schema.d.ts +130 -0
  50. package/dist/modules/auth/schema.d.ts.map +1 -1
  51. package/dist/modules/auth/schema.js +16 -0
  52. package/dist/modules/auth/schema.js.map +1 -1
  53. package/dist/modules/auth/service.d.ts +7 -1
  54. package/dist/modules/auth/service.d.ts.map +1 -1
  55. package/dist/modules/auth/service.js +53 -4
  56. package/dist/modules/auth/service.js.map +1 -1
  57. package/dist/modules/deals/repository.d.ts.map +1 -1
  58. package/dist/modules/deals/repository.js +1 -0
  59. package/dist/modules/deals/repository.js.map +1 -1
  60. package/dist/modules/decisions/repository.d.ts.map +1 -1
  61. package/dist/modules/decisions/repository.js +8 -7
  62. package/dist/modules/decisions/repository.js.map +1 -1
  63. package/dist/modules/forms/index.d.ts.map +1 -1
  64. package/dist/modules/forms/index.js +6 -1
  65. package/dist/modules/forms/index.js.map +1 -1
  66. package/dist/modules/forms/publicRoutes.d.ts +3 -0
  67. package/dist/modules/forms/publicRoutes.d.ts.map +1 -1
  68. package/dist/modules/forms/publicRoutes.js +10 -4
  69. package/dist/modules/forms/publicRoutes.js.map +1 -1
  70. package/dist/modules/forms/submission.d.ts +3 -0
  71. package/dist/modules/forms/submission.d.ts.map +1 -1
  72. package/dist/modules/forms/submission.js +6 -0
  73. package/dist/modules/forms/submission.js.map +1 -1
  74. package/dist/modules/opportunities/repository.d.ts.map +1 -1
  75. package/dist/modules/opportunities/repository.js +1 -0
  76. package/dist/modules/opportunities/repository.js.map +1 -1
  77. package/dist/modules/partnerships/repository.d.ts.map +1 -1
  78. package/dist/modules/partnerships/repository.js +1 -0
  79. package/dist/modules/partnerships/repository.js.map +1 -1
  80. package/dist/modules/raises/repository.d.ts.map +1 -1
  81. package/dist/modules/raises/repository.js +1 -0
  82. package/dist/modules/raises/repository.js.map +1 -1
  83. package/dist/modules/rate-limit/middleware.d.ts +4 -4
  84. package/dist/modules/rate-limit/middleware.d.ts.map +1 -1
  85. package/dist/modules/rate-limit/middleware.js +57 -11
  86. package/dist/modules/rate-limit/middleware.js.map +1 -1
  87. package/dist/modules/webhooks/delivery.d.ts +5 -2
  88. package/dist/modules/webhooks/delivery.d.ts.map +1 -1
  89. package/dist/modules/webhooks/delivery.js +5 -2
  90. package/dist/modules/webhooks/delivery.js.map +1 -1
  91. package/dist/modules/webhooks/index.d.ts.map +1 -1
  92. package/dist/modules/webhooks/index.js +3 -1
  93. package/dist/modules/webhooks/index.js.map +1 -1
  94. package/dist/modules/workspace/capabilities.d.ts +10 -1
  95. package/dist/modules/workspace/capabilities.d.ts.map +1 -1
  96. package/dist/modules/workspace/capabilities.js +13 -0
  97. package/dist/modules/workspace/capabilities.js.map +1 -1
  98. package/dist/modules/workspace/idempotencyMiddleware.d.ts.map +1 -1
  99. package/dist/modules/workspace/idempotencyMiddleware.js +14 -1
  100. package/dist/modules/workspace/idempotencyMiddleware.js.map +1 -1
  101. package/dist/modules/workspace/index.d.ts.map +1 -1
  102. package/dist/modules/workspace/index.js +5 -1
  103. package/dist/modules/workspace/index.js.map +1 -1
  104. package/dist/modules/workspace/repository.d.ts +1 -1
  105. package/dist/modules/workspace/repository.d.ts.map +1 -1
  106. package/dist/modules/workspace/repository.js +15 -9
  107. package/dist/modules/workspace/repository.js.map +1 -1
  108. package/dist/modules/workspace/routes.d.ts +0 -4
  109. package/dist/modules/workspace/routes.d.ts.map +1 -1
  110. package/dist/modules/workspace/routes.js +2 -7
  111. package/dist/modules/workspace/routes.js.map +1 -1
  112. package/dist/modules/workspace/service.d.ts +4 -2
  113. package/dist/modules/workspace/service.d.ts.map +1 -1
  114. package/dist/modules/workspace/service.js +42 -17
  115. package/dist/modules/workspace/service.js.map +1 -1
  116. package/dist/modules/workspace/tools.d.ts.map +1 -1
  117. package/dist/modules/workspace/tools.js +6 -7
  118. package/dist/modules/workspace/tools.js.map +1 -1
  119. package/dist/modules/workspace/workspaceAccessMiddleware.d.ts +8 -0
  120. package/dist/modules/workspace/workspaceAccessMiddleware.d.ts.map +1 -0
  121. package/dist/modules/workspace/workspaceAccessMiddleware.js +58 -0
  122. package/dist/modules/workspace/workspaceAccessMiddleware.js.map +1 -0
  123. package/dist/operator.d.ts +40 -0
  124. package/dist/operator.d.ts.map +1 -0
  125. package/dist/operator.js +50 -0
  126. package/dist/operator.js.map +1 -0
  127. package/dist/runtime/module.d.ts +25 -1
  128. package/dist/runtime/module.d.ts.map +1 -1
  129. package/dist/runtime/registry.d.ts +16 -1
  130. package/dist/runtime/registry.d.ts.map +1 -1
  131. package/dist/runtime/registry.js +34 -0
  132. package/dist/runtime/registry.js.map +1 -1
  133. package/dist/testing/client.d.ts +3 -1
  134. package/dist/testing/client.d.ts.map +1 -1
  135. package/dist/testing/client.js +19 -2
  136. package/dist/testing/client.js.map +1 -1
  137. package/dist/testing/environment.d.ts +2 -0
  138. package/dist/testing/environment.d.ts.map +1 -1
  139. package/dist/testing/environment.js +3 -0
  140. package/dist/testing/environment.js.map +1 -1
  141. package/dist/webBundle.d.ts +8 -0
  142. package/dist/webBundle.d.ts.map +1 -1
  143. package/dist/webBundle.js +9 -7
  144. package/dist/webBundle.js.map +1 -1
  145. package/migrations/0018_hard_mystique.sql +12 -0
  146. package/migrations/meta/0018_snapshot.json +5113 -0
  147. package/migrations/meta/_journal.json +8 -1
  148. package/package.json +2 -2
  149. package/src/app.ts +32 -0
  150. package/src/index.ts +9 -1
  151. package/src/lib/appUrl.ts +64 -0
  152. package/src/lib/clientIp.ts +41 -0
  153. package/src/lib/config.ts +8 -0
  154. package/src/lib/egress.ts +149 -0
  155. package/src/lib/ids.ts +2 -0
  156. package/src/lib/rateLimit.ts +16 -4
  157. package/src/modules/agent-tasks/dispatch.ts +9 -2
  158. package/src/modules/agent-tasks/index.ts +3 -1
  159. package/src/modules/auth/index.ts +12 -5
  160. package/src/modules/auth/repository.ts +56 -1
  161. package/src/modules/auth/routes.ts +34 -6
  162. package/src/modules/auth/schema.ts +17 -0
  163. package/src/modules/auth/service.ts +78 -5
  164. package/src/modules/deals/repository.ts +1 -0
  165. package/src/modules/decisions/repository.ts +8 -7
  166. package/src/modules/forms/index.ts +6 -1
  167. package/src/modules/forms/publicRoutes.ts +14 -4
  168. package/src/modules/forms/submission.ts +10 -0
  169. package/src/modules/opportunities/repository.ts +1 -0
  170. package/src/modules/partnerships/repository.ts +1 -0
  171. package/src/modules/raises/repository.ts +1 -0
  172. package/src/modules/rate-limit/middleware.ts +74 -11
  173. package/src/modules/webhooks/delivery.ts +10 -2
  174. package/src/modules/webhooks/index.ts +3 -1
  175. package/src/modules/workspace/capabilities.ts +15 -1
  176. package/src/modules/workspace/idempotencyMiddleware.ts +17 -1
  177. package/src/modules/workspace/index.ts +6 -1
  178. package/src/modules/workspace/repository.ts +15 -8
  179. package/src/modules/workspace/routes.ts +0 -8
  180. package/src/modules/workspace/service.ts +53 -20
  181. package/src/modules/workspace/tools.ts +7 -21
  182. package/src/modules/workspace/workspaceAccessMiddleware.ts +76 -0
  183. package/src/runtime/module.ts +25 -1
  184. package/src/runtime/registry.ts +59 -1
  185. package/src/testing/client.ts +22 -2
  186. package/src/testing/environment.ts +4 -0
  187. 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: { id: issued.account.id, email: issued.account.email, name: issued.account.name },
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 { id: account.id, email: account.email, name: account.name }
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({ ...body, ...describeClient(context) })
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, body.reset_url_template)
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, resetUrlTemplate: string): Promise<void>
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
- return dependencies.transaction(async ({ tx }) => {
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, resetUrlTemplate: string): Promise<void> {
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${resetUrlTemplate.replace('{token}', token)}\n\nIf you did not ask for this, ignore it.`,
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
  }
@@ -44,6 +44,7 @@ function companyNameMatches(pattern: string): SQL {
44
44
  select 1
45
45
  from ${companies}
46
46
  where ${companies.id} = ${deals.companyId}
47
+ and ${companies.workspaceId} = ${deals.workspaceId}
47
48
  and ${companies.name} ilike ${pattern}
48
49
  )`
49
50
  }
@@ -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, { db: context.db, submissions })
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
  }
@@ -43,6 +43,7 @@ function companyNameMatches(pattern: string): SQL {
43
43
  select 1
44
44
  from ${companies}
45
45
  where ${companies.id} = ${opportunities.companyId}
46
+ and ${companies.workspaceId} = ${opportunities.workspaceId}
46
47
  and ${companies.name} ilike ${pattern}
47
48
  )`
48
49
  }
@@ -46,6 +46,7 @@ function companyNameMatches(pattern: string): SQL {
46
46
  select 1
47
47
  from ${companies}
48
48
  where ${companies.id} = ${partnerships.companyId}
49
+ and ${companies.workspaceId} = ${partnerships.workspaceId}
49
50
  and ${companies.name} ilike ${pattern}
50
51
  )`
51
52
  }
@@ -47,6 +47,7 @@ function companyNameMatches(pattern: string): SQL {
47
47
  select 1
48
48
  from ${companies}
49
49
  where ${companies.id} = ${raises.companyId}
50
+ and ${companies.workspaceId} = ${raises.workspaceId}
50
51
  and ${companies.name} ilike ${pattern}
51
52
  )`
52
53
  }
@@ -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
- function isFormSubmitRoute(context: Context): boolean {
45
- return context.req.method === 'POST' && context.req.path.endsWith('/submit')
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 all three budgets. A bucket cannot be
55
- * expired before its own window elapses, so this is a safe cutoff for
56
- * pruning regardless of which budget created the row.
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(config.forms.windowMs, config.auth.windowMs, config.api.windowMs)
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 `POST /v1/public/forms/:key/submit`, keyed by the caller's IP.
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`. It only recognises the
100
- * submit route; every other path under the public prefix, including the embed
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 (!isFormSubmitRoute(context)) {
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(fetchImplementation: typeof fetch = fetch): SendDelivery {
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
+ }