@kelpie/server 0.4.0 → 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 (182) 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 +11 -0
  17. package/dist/lib/config.d.ts.map +1 -1
  18. package/dist/lib/config.js +23 -12
  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/email.d.ts +53 -9
  25. package/dist/lib/email.d.ts.map +1 -1
  26. package/dist/lib/email.js +49 -4
  27. package/dist/lib/email.js.map +1 -1
  28. package/dist/lib/ids.d.ts +2 -0
  29. package/dist/lib/ids.d.ts.map +1 -1
  30. package/dist/lib/ids.js +2 -0
  31. package/dist/lib/ids.js.map +1 -1
  32. package/dist/lib/rateLimit.d.ts +12 -4
  33. package/dist/lib/rateLimit.d.ts.map +1 -1
  34. package/dist/lib/rateLimit.js +6 -0
  35. package/dist/lib/rateLimit.js.map +1 -1
  36. package/dist/modules/agent-tasks/dispatch.d.ts +4 -2
  37. package/dist/modules/agent-tasks/dispatch.d.ts.map +1 -1
  38. package/dist/modules/agent-tasks/dispatch.js +4 -2
  39. package/dist/modules/agent-tasks/dispatch.js.map +1 -1
  40. package/dist/modules/agent-tasks/index.d.ts.map +1 -1
  41. package/dist/modules/agent-tasks/index.js +3 -1
  42. package/dist/modules/agent-tasks/index.js.map +1 -1
  43. package/dist/modules/auth/index.d.ts.map +1 -1
  44. package/dist/modules/auth/index.js +10 -3
  45. package/dist/modules/auth/index.js.map +1 -1
  46. package/dist/modules/auth/repository.d.ts +13 -1
  47. package/dist/modules/auth/repository.d.ts.map +1 -1
  48. package/dist/modules/auth/repository.js +32 -1
  49. package/dist/modules/auth/repository.js.map +1 -1
  50. package/dist/modules/auth/routes.d.ts.map +1 -1
  51. package/dist/modules/auth/routes.js +29 -6
  52. package/dist/modules/auth/routes.js.map +1 -1
  53. package/dist/modules/auth/schema.d.ts +130 -0
  54. package/dist/modules/auth/schema.d.ts.map +1 -1
  55. package/dist/modules/auth/schema.js +16 -0
  56. package/dist/modules/auth/schema.js.map +1 -1
  57. package/dist/modules/auth/service.d.ts +7 -1
  58. package/dist/modules/auth/service.d.ts.map +1 -1
  59. package/dist/modules/auth/service.js +53 -4
  60. package/dist/modules/auth/service.js.map +1 -1
  61. package/dist/modules/deals/repository.d.ts.map +1 -1
  62. package/dist/modules/deals/repository.js +1 -0
  63. package/dist/modules/deals/repository.js.map +1 -1
  64. package/dist/modules/decisions/repository.d.ts.map +1 -1
  65. package/dist/modules/decisions/repository.js +8 -7
  66. package/dist/modules/decisions/repository.js.map +1 -1
  67. package/dist/modules/forms/index.d.ts.map +1 -1
  68. package/dist/modules/forms/index.js +6 -1
  69. package/dist/modules/forms/index.js.map +1 -1
  70. package/dist/modules/forms/publicRoutes.d.ts +3 -0
  71. package/dist/modules/forms/publicRoutes.d.ts.map +1 -1
  72. package/dist/modules/forms/publicRoutes.js +10 -4
  73. package/dist/modules/forms/publicRoutes.js.map +1 -1
  74. package/dist/modules/forms/submission.d.ts +3 -0
  75. package/dist/modules/forms/submission.d.ts.map +1 -1
  76. package/dist/modules/forms/submission.js +6 -0
  77. package/dist/modules/forms/submission.js.map +1 -1
  78. package/dist/modules/opportunities/repository.d.ts.map +1 -1
  79. package/dist/modules/opportunities/repository.js +1 -0
  80. package/dist/modules/opportunities/repository.js.map +1 -1
  81. package/dist/modules/partnerships/repository.d.ts.map +1 -1
  82. package/dist/modules/partnerships/repository.js +1 -0
  83. package/dist/modules/partnerships/repository.js.map +1 -1
  84. package/dist/modules/raises/repository.d.ts.map +1 -1
  85. package/dist/modules/raises/repository.js +1 -0
  86. package/dist/modules/raises/repository.js.map +1 -1
  87. package/dist/modules/rate-limit/middleware.d.ts +4 -4
  88. package/dist/modules/rate-limit/middleware.d.ts.map +1 -1
  89. package/dist/modules/rate-limit/middleware.js +57 -11
  90. package/dist/modules/rate-limit/middleware.js.map +1 -1
  91. package/dist/modules/webhooks/delivery.d.ts +5 -2
  92. package/dist/modules/webhooks/delivery.d.ts.map +1 -1
  93. package/dist/modules/webhooks/delivery.js +5 -2
  94. package/dist/modules/webhooks/delivery.js.map +1 -1
  95. package/dist/modules/webhooks/index.d.ts.map +1 -1
  96. package/dist/modules/webhooks/index.js +3 -1
  97. package/dist/modules/webhooks/index.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 +3 -0
  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/operator.d.ts +40 -0
  120. package/dist/operator.d.ts.map +1 -0
  121. package/dist/operator.js +50 -0
  122. package/dist/operator.js.map +1 -0
  123. package/dist/runtime/module.d.ts +25 -1
  124. package/dist/runtime/module.d.ts.map +1 -1
  125. package/dist/runtime/registry.d.ts +16 -1
  126. package/dist/runtime/registry.d.ts.map +1 -1
  127. package/dist/runtime/registry.js +34 -0
  128. package/dist/runtime/registry.js.map +1 -1
  129. package/dist/testing/client.d.ts +3 -1
  130. package/dist/testing/client.d.ts.map +1 -1
  131. package/dist/testing/client.js +19 -2
  132. package/dist/testing/client.js.map +1 -1
  133. package/dist/testing/environment.d.ts +2 -0
  134. package/dist/testing/environment.d.ts.map +1 -1
  135. package/dist/testing/environment.js +3 -0
  136. package/dist/testing/environment.js.map +1 -1
  137. package/dist/webBundle.d.ts +8 -0
  138. package/dist/webBundle.d.ts.map +1 -1
  139. package/dist/webBundle.js +9 -7
  140. package/dist/webBundle.js.map +1 -1
  141. package/migrations/0018_hard_mystique.sql +12 -0
  142. package/migrations/meta/0018_snapshot.json +5113 -0
  143. package/migrations/meta/_journal.json +8 -1
  144. package/package.json +4 -2
  145. package/src/app.ts +16 -0
  146. package/src/index.ts +9 -1
  147. package/src/lib/appUrl.ts +64 -0
  148. package/src/lib/clientIp.ts +41 -0
  149. package/src/lib/config.ts +30 -12
  150. package/src/lib/egress.ts +149 -0
  151. package/src/lib/email.ts +75 -7
  152. package/src/lib/ids.ts +2 -0
  153. package/src/lib/rateLimit.ts +16 -4
  154. package/src/modules/agent-tasks/dispatch.ts +9 -2
  155. package/src/modules/agent-tasks/index.ts +3 -1
  156. package/src/modules/auth/index.ts +12 -5
  157. package/src/modules/auth/repository.ts +56 -1
  158. package/src/modules/auth/routes.ts +34 -6
  159. package/src/modules/auth/schema.ts +17 -0
  160. package/src/modules/auth/service.ts +78 -5
  161. package/src/modules/deals/repository.ts +1 -0
  162. package/src/modules/decisions/repository.ts +8 -7
  163. package/src/modules/forms/index.ts +6 -1
  164. package/src/modules/forms/publicRoutes.ts +14 -4
  165. package/src/modules/forms/submission.ts +10 -0
  166. package/src/modules/opportunities/repository.ts +1 -0
  167. package/src/modules/partnerships/repository.ts +1 -0
  168. package/src/modules/raises/repository.ts +1 -0
  169. package/src/modules/rate-limit/middleware.ts +74 -11
  170. package/src/modules/webhooks/delivery.ts +10 -2
  171. package/src/modules/webhooks/index.ts +3 -1
  172. package/src/modules/workspace/idempotencyMiddleware.ts +17 -1
  173. package/src/modules/workspace/index.ts +4 -0
  174. package/src/modules/workspace/repository.ts +15 -8
  175. package/src/modules/workspace/routes.ts +0 -8
  176. package/src/modules/workspace/service.ts +53 -20
  177. package/src/modules/workspace/tools.ts +7 -21
  178. package/src/runtime/module.ts +25 -1
  179. package/src/runtime/registry.ts +59 -1
  180. package/src/testing/client.ts +22 -2
  181. package/src/testing/environment.ts +4 -0
  182. package/src/webBundle.ts +21 -7
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod'
2
2
 
3
+ import { appUrlConfigSchema } from '../../lib/appUrl.ts'
3
4
  import type { KelpieModule } from '../../runtime/module.ts'
4
5
  import { mountAuthRoutes } from './routes.ts'
5
6
  import * as schema from './schema.ts'
@@ -12,10 +13,12 @@ import { createAuthService } from './service.ts'
12
13
  * `users`.
13
14
  */
14
15
 
15
- const authConfigSchema = z.object({
16
- /** Cookies go out `Secure` everywhere except development. */
17
- NODE_ENV: z.enum(['development', 'test', 'production']),
18
- })
16
+ const authConfigSchema = z
17
+ .object({
18
+ /** Cookies go out `Secure` everywhere except development. */
19
+ NODE_ENV: z.enum(['development', 'test', 'production']),
20
+ })
21
+ .merge(appUrlConfigSchema)
19
22
 
20
23
  export function createAuthModule(migrationsDirectory: string): KelpieModule {
21
24
  return {
@@ -30,6 +33,7 @@ export function createAuthModule(migrationsDirectory: string): KelpieModule {
30
33
  email: context.email,
31
34
  createId: context.createId,
32
35
  now: context.now,
36
+ appBaseUrl: config.APP_BASE_URL,
33
37
  })
34
38
 
35
39
  context.schema(schema, migrationsDirectory)
@@ -39,7 +43,10 @@ export function createAuthModule(migrationsDirectory: string): KelpieModule {
39
43
  db: context.db,
40
44
  now: context.now,
41
45
  service,
42
- cookie: { secure: config.NODE_ENV === 'production' },
46
+ // `Secure` everywhere except development. A test host reaches the API
47
+ // over http through a test client, which stores the cookie regardless
48
+ // of the flag, so test is not excluded.
49
+ cookie: { secure: config.NODE_ENV !== 'development' },
43
50
  })
44
51
  })
45
52
 
@@ -3,7 +3,13 @@ import { and, desc, eq, gt, isNull, ne } from 'drizzle-orm'
3
3
  import type { Database } from '../../lib/database.ts'
4
4
  import type { Transaction } from '../../runtime/transaction.ts'
5
5
  import { workspaceMembers } from '../workspace/schema.ts'
6
- import { passwordResetTokens, sessions, userPreferences, users } from './schema.ts'
6
+ import {
7
+ emailVerificationTokens,
8
+ passwordResetTokens,
9
+ sessions,
10
+ userPreferences,
11
+ users,
12
+ } from './schema.ts'
7
13
 
8
14
  /**
9
15
  * Drizzle queries for accounts and sessions. No business logic: the service
@@ -209,6 +215,55 @@ export async function markPasswordResetTokenUsed(db: Queryable, id: string, now:
209
215
  .where(eq(passwordResetTokens.id, id))
210
216
  }
211
217
 
218
+ export async function insertEmailVerificationToken(
219
+ db: Queryable,
220
+ values: typeof emailVerificationTokens.$inferInsert,
221
+ ): Promise<void> {
222
+ await db.insert(emailVerificationTokens).values(values)
223
+ }
224
+
225
+ /** Unused and unexpired. A used or stale token is not a token. */
226
+ export async function findUsableEmailVerificationToken(
227
+ db: Queryable,
228
+ tokenHash: string,
229
+ now: Date,
230
+ ): Promise<typeof emailVerificationTokens.$inferSelect | undefined> {
231
+ const [found] = await db
232
+ .select()
233
+ .from(emailVerificationTokens)
234
+ .where(
235
+ and(
236
+ eq(emailVerificationTokens.tokenHash, tokenHash),
237
+ isNull(emailVerificationTokens.usedAt),
238
+ gt(emailVerificationTokens.expiresAt, now),
239
+ ),
240
+ )
241
+ .limit(1)
242
+
243
+ return found
244
+ }
245
+
246
+ export async function markEmailVerificationTokenUsed(db: Queryable, id: string, now: Date): Promise<void> {
247
+ await db
248
+ .update(emailVerificationTokens)
249
+ .set({ usedAt: now })
250
+ .where(eq(emailVerificationTokens.id, id))
251
+ }
252
+
253
+ /**
254
+ * Sets `email_verified_at` if it is not already set.
255
+ *
256
+ * Guarded in the `WHERE` clause rather than read-then-write, so accepting a
257
+ * second invite or confirming a stale token after the account is already
258
+ * verified is a no-op instead of overwriting the original verification time.
259
+ */
260
+ export async function markEmailVerified(db: Queryable, userId: string, now: Date): Promise<void> {
261
+ await db
262
+ .update(users)
263
+ .set({ emailVerifiedAt: now })
264
+ .where(and(eq(users.id, userId), isNull(users.emailVerifiedAt)))
265
+ }
266
+
212
267
  /** The membership a session's active workspace resolves to, if any. */
213
268
  export async function findMembership(
214
269
  db: Queryable,
@@ -31,8 +31,6 @@ const logInBody = z.object({
31
31
 
32
32
  const resetRequestBody = z.object({
33
33
  email: z.string().min(1),
34
- /** Where the emailed link points. `{token}` is replaced with the reset token. */
35
- reset_url_template: z.string().min(1).includes('{token}'),
36
34
  })
37
35
 
38
36
  const resetConfirmBody = z.object({
@@ -45,6 +43,10 @@ const changePasswordBody = z.object({
45
43
  new_password: z.string().min(1),
46
44
  })
47
45
 
46
+ const verifyEmailConfirmBody = z.object({
47
+ token: z.string().min(1),
48
+ })
49
+
48
50
  const updateAccountBody = z
49
51
  .strictObject({
50
52
  name: z.string().min(1),
@@ -95,13 +97,18 @@ function describeClient(context: Context): { device?: string; location?: string
95
97
 
96
98
  function accountResponse(issued: IssuedSession): Record<string, unknown> {
97
99
  return {
98
- account: { 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
  }