@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.
Files changed (177) hide show
  1. package/dist/app.d.ts.map +1 -1
  2. package/dist/app.js +14 -0
  3. package/dist/app.js.map +1 -1
  4. package/dist/index.d.ts +2 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +1 -0
  7. package/dist/index.js.map +1 -1
  8. package/dist/lib/appUrl.d.ts +27 -0
  9. package/dist/lib/appUrl.d.ts.map +1 -0
  10. package/dist/lib/appUrl.js +53 -0
  11. package/dist/lib/appUrl.js.map +1 -0
  12. package/dist/lib/clientIp.d.ts +24 -0
  13. package/dist/lib/clientIp.d.ts.map +1 -0
  14. package/dist/lib/clientIp.js +34 -0
  15. package/dist/lib/clientIp.js.map +1 -0
  16. package/dist/lib/config.d.ts +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/idempotencyMiddleware.d.ts.map +1 -1
  95. package/dist/modules/workspace/idempotencyMiddleware.js +14 -1
  96. package/dist/modules/workspace/idempotencyMiddleware.js.map +1 -1
  97. package/dist/modules/workspace/index.d.ts.map +1 -1
  98. package/dist/modules/workspace/index.js +3 -0
  99. package/dist/modules/workspace/index.js.map +1 -1
  100. package/dist/modules/workspace/repository.d.ts +1 -1
  101. package/dist/modules/workspace/repository.d.ts.map +1 -1
  102. package/dist/modules/workspace/repository.js +15 -9
  103. package/dist/modules/workspace/repository.js.map +1 -1
  104. package/dist/modules/workspace/routes.d.ts +0 -4
  105. package/dist/modules/workspace/routes.d.ts.map +1 -1
  106. package/dist/modules/workspace/routes.js +2 -7
  107. package/dist/modules/workspace/routes.js.map +1 -1
  108. package/dist/modules/workspace/service.d.ts +4 -2
  109. package/dist/modules/workspace/service.d.ts.map +1 -1
  110. package/dist/modules/workspace/service.js +42 -17
  111. package/dist/modules/workspace/service.js.map +1 -1
  112. package/dist/modules/workspace/tools.d.ts.map +1 -1
  113. package/dist/modules/workspace/tools.js +6 -7
  114. package/dist/modules/workspace/tools.js.map +1 -1
  115. package/dist/operator.d.ts +40 -0
  116. package/dist/operator.d.ts.map +1 -0
  117. package/dist/operator.js +50 -0
  118. package/dist/operator.js.map +1 -0
  119. package/dist/runtime/module.d.ts +25 -1
  120. package/dist/runtime/module.d.ts.map +1 -1
  121. package/dist/runtime/registry.d.ts +16 -1
  122. package/dist/runtime/registry.d.ts.map +1 -1
  123. package/dist/runtime/registry.js +34 -0
  124. package/dist/runtime/registry.js.map +1 -1
  125. package/dist/testing/client.d.ts +3 -1
  126. package/dist/testing/client.d.ts.map +1 -1
  127. package/dist/testing/client.js +19 -2
  128. package/dist/testing/client.js.map +1 -1
  129. package/dist/testing/environment.d.ts +2 -0
  130. package/dist/testing/environment.d.ts.map +1 -1
  131. package/dist/testing/environment.js +3 -0
  132. package/dist/testing/environment.js.map +1 -1
  133. package/dist/webBundle.d.ts +8 -0
  134. package/dist/webBundle.d.ts.map +1 -1
  135. package/dist/webBundle.js +9 -7
  136. package/dist/webBundle.js.map +1 -1
  137. package/migrations/0018_hard_mystique.sql +12 -0
  138. package/migrations/meta/0018_snapshot.json +5113 -0
  139. package/migrations/meta/_journal.json +8 -1
  140. package/package.json +2 -2
  141. package/src/app.ts +16 -0
  142. package/src/index.ts +9 -1
  143. package/src/lib/appUrl.ts +64 -0
  144. package/src/lib/clientIp.ts +41 -0
  145. package/src/lib/config.ts +8 -0
  146. package/src/lib/egress.ts +149 -0
  147. package/src/lib/ids.ts +2 -0
  148. package/src/lib/rateLimit.ts +16 -4
  149. package/src/modules/agent-tasks/dispatch.ts +9 -2
  150. package/src/modules/agent-tasks/index.ts +3 -1
  151. package/src/modules/auth/index.ts +12 -5
  152. package/src/modules/auth/repository.ts +56 -1
  153. package/src/modules/auth/routes.ts +34 -6
  154. package/src/modules/auth/schema.ts +17 -0
  155. package/src/modules/auth/service.ts +78 -5
  156. package/src/modules/deals/repository.ts +1 -0
  157. package/src/modules/decisions/repository.ts +8 -7
  158. package/src/modules/forms/index.ts +6 -1
  159. package/src/modules/forms/publicRoutes.ts +14 -4
  160. package/src/modules/forms/submission.ts +10 -0
  161. package/src/modules/opportunities/repository.ts +1 -0
  162. package/src/modules/partnerships/repository.ts +1 -0
  163. package/src/modules/raises/repository.ts +1 -0
  164. package/src/modules/rate-limit/middleware.ts +74 -11
  165. package/src/modules/webhooks/delivery.ts +10 -2
  166. package/src/modules/webhooks/index.ts +3 -1
  167. package/src/modules/workspace/idempotencyMiddleware.ts +17 -1
  168. package/src/modules/workspace/index.ts +4 -0
  169. package/src/modules/workspace/repository.ts +15 -8
  170. package/src/modules/workspace/routes.ts +0 -8
  171. package/src/modules/workspace/service.ts +53 -20
  172. package/src/modules/workspace/tools.ts +7 -21
  173. package/src/runtime/module.ts +25 -1
  174. package/src/runtime/registry.ts +59 -1
  175. package/src/testing/client.ts +22 -2
  176. package/src/testing/environment.ts +4 -0
  177. 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, 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,
@@ -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
- const [row] = await db.select({ total: count() }).from(source.table).where(eq(source.column, memberId))
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))