@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.
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +14 -0
- package/dist/app.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/appUrl.d.ts +27 -0
- package/dist/lib/appUrl.d.ts.map +1 -0
- package/dist/lib/appUrl.js +53 -0
- package/dist/lib/appUrl.js.map +1 -0
- package/dist/lib/clientIp.d.ts +24 -0
- package/dist/lib/clientIp.d.ts.map +1 -0
- package/dist/lib/clientIp.js +34 -0
- package/dist/lib/clientIp.js.map +1 -0
- package/dist/lib/config.d.ts +11 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +23 -12
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/egress.d.ts +44 -0
- package/dist/lib/egress.d.ts.map +1 -0
- package/dist/lib/egress.js +111 -0
- package/dist/lib/egress.js.map +1 -0
- package/dist/lib/email.d.ts +53 -9
- package/dist/lib/email.d.ts.map +1 -1
- package/dist/lib/email.js +49 -4
- package/dist/lib/email.js.map +1 -1
- package/dist/lib/ids.d.ts +2 -0
- package/dist/lib/ids.d.ts.map +1 -1
- package/dist/lib/ids.js +2 -0
- package/dist/lib/ids.js.map +1 -1
- package/dist/lib/rateLimit.d.ts +12 -4
- package/dist/lib/rateLimit.d.ts.map +1 -1
- package/dist/lib/rateLimit.js +6 -0
- package/dist/lib/rateLimit.js.map +1 -1
- package/dist/modules/agent-tasks/dispatch.d.ts +4 -2
- package/dist/modules/agent-tasks/dispatch.d.ts.map +1 -1
- package/dist/modules/agent-tasks/dispatch.js +4 -2
- package/dist/modules/agent-tasks/dispatch.js.map +1 -1
- package/dist/modules/agent-tasks/index.d.ts.map +1 -1
- package/dist/modules/agent-tasks/index.js +3 -1
- package/dist/modules/agent-tasks/index.js.map +1 -1
- package/dist/modules/auth/index.d.ts.map +1 -1
- package/dist/modules/auth/index.js +10 -3
- package/dist/modules/auth/index.js.map +1 -1
- package/dist/modules/auth/repository.d.ts +13 -1
- package/dist/modules/auth/repository.d.ts.map +1 -1
- package/dist/modules/auth/repository.js +32 -1
- package/dist/modules/auth/repository.js.map +1 -1
- package/dist/modules/auth/routes.d.ts.map +1 -1
- package/dist/modules/auth/routes.js +29 -6
- package/dist/modules/auth/routes.js.map +1 -1
- package/dist/modules/auth/schema.d.ts +130 -0
- package/dist/modules/auth/schema.d.ts.map +1 -1
- package/dist/modules/auth/schema.js +16 -0
- package/dist/modules/auth/schema.js.map +1 -1
- package/dist/modules/auth/service.d.ts +7 -1
- package/dist/modules/auth/service.d.ts.map +1 -1
- package/dist/modules/auth/service.js +53 -4
- package/dist/modules/auth/service.js.map +1 -1
- package/dist/modules/deals/repository.d.ts.map +1 -1
- package/dist/modules/deals/repository.js +1 -0
- package/dist/modules/deals/repository.js.map +1 -1
- package/dist/modules/decisions/repository.d.ts.map +1 -1
- package/dist/modules/decisions/repository.js +8 -7
- package/dist/modules/decisions/repository.js.map +1 -1
- package/dist/modules/forms/index.d.ts.map +1 -1
- package/dist/modules/forms/index.js +6 -1
- package/dist/modules/forms/index.js.map +1 -1
- package/dist/modules/forms/publicRoutes.d.ts +3 -0
- package/dist/modules/forms/publicRoutes.d.ts.map +1 -1
- package/dist/modules/forms/publicRoutes.js +10 -4
- package/dist/modules/forms/publicRoutes.js.map +1 -1
- package/dist/modules/forms/submission.d.ts +3 -0
- package/dist/modules/forms/submission.d.ts.map +1 -1
- package/dist/modules/forms/submission.js +6 -0
- package/dist/modules/forms/submission.js.map +1 -1
- package/dist/modules/opportunities/repository.d.ts.map +1 -1
- package/dist/modules/opportunities/repository.js +1 -0
- package/dist/modules/opportunities/repository.js.map +1 -1
- package/dist/modules/partnerships/repository.d.ts.map +1 -1
- package/dist/modules/partnerships/repository.js +1 -0
- package/dist/modules/partnerships/repository.js.map +1 -1
- package/dist/modules/raises/repository.d.ts.map +1 -1
- package/dist/modules/raises/repository.js +1 -0
- package/dist/modules/raises/repository.js.map +1 -1
- package/dist/modules/rate-limit/middleware.d.ts +4 -4
- package/dist/modules/rate-limit/middleware.d.ts.map +1 -1
- package/dist/modules/rate-limit/middleware.js +57 -11
- package/dist/modules/rate-limit/middleware.js.map +1 -1
- package/dist/modules/webhooks/delivery.d.ts +5 -2
- package/dist/modules/webhooks/delivery.d.ts.map +1 -1
- package/dist/modules/webhooks/delivery.js +5 -2
- package/dist/modules/webhooks/delivery.js.map +1 -1
- package/dist/modules/webhooks/index.d.ts.map +1 -1
- package/dist/modules/webhooks/index.js +3 -1
- package/dist/modules/webhooks/index.js.map +1 -1
- package/dist/modules/workspace/idempotencyMiddleware.d.ts.map +1 -1
- package/dist/modules/workspace/idempotencyMiddleware.js +14 -1
- package/dist/modules/workspace/idempotencyMiddleware.js.map +1 -1
- package/dist/modules/workspace/index.d.ts.map +1 -1
- package/dist/modules/workspace/index.js +3 -0
- package/dist/modules/workspace/index.js.map +1 -1
- package/dist/modules/workspace/repository.d.ts +1 -1
- package/dist/modules/workspace/repository.d.ts.map +1 -1
- package/dist/modules/workspace/repository.js +15 -9
- package/dist/modules/workspace/repository.js.map +1 -1
- package/dist/modules/workspace/routes.d.ts +0 -4
- package/dist/modules/workspace/routes.d.ts.map +1 -1
- package/dist/modules/workspace/routes.js +2 -7
- package/dist/modules/workspace/routes.js.map +1 -1
- package/dist/modules/workspace/service.d.ts +4 -2
- package/dist/modules/workspace/service.d.ts.map +1 -1
- package/dist/modules/workspace/service.js +42 -17
- package/dist/modules/workspace/service.js.map +1 -1
- package/dist/modules/workspace/tools.d.ts.map +1 -1
- package/dist/modules/workspace/tools.js +6 -7
- package/dist/modules/workspace/tools.js.map +1 -1
- package/dist/operator.d.ts +40 -0
- package/dist/operator.d.ts.map +1 -0
- package/dist/operator.js +50 -0
- package/dist/operator.js.map +1 -0
- package/dist/runtime/module.d.ts +25 -1
- package/dist/runtime/module.d.ts.map +1 -1
- package/dist/runtime/registry.d.ts +16 -1
- package/dist/runtime/registry.d.ts.map +1 -1
- package/dist/runtime/registry.js +34 -0
- package/dist/runtime/registry.js.map +1 -1
- package/dist/testing/client.d.ts +3 -1
- package/dist/testing/client.d.ts.map +1 -1
- package/dist/testing/client.js +19 -2
- package/dist/testing/client.js.map +1 -1
- package/dist/testing/environment.d.ts +2 -0
- package/dist/testing/environment.d.ts.map +1 -1
- package/dist/testing/environment.js +3 -0
- package/dist/testing/environment.js.map +1 -1
- package/dist/webBundle.d.ts +8 -0
- package/dist/webBundle.d.ts.map +1 -1
- package/dist/webBundle.js +9 -7
- package/dist/webBundle.js.map +1 -1
- package/migrations/0018_hard_mystique.sql +12 -0
- package/migrations/meta/0018_snapshot.json +5113 -0
- package/migrations/meta/_journal.json +8 -1
- package/package.json +4 -2
- package/src/app.ts +16 -0
- package/src/index.ts +9 -1
- package/src/lib/appUrl.ts +64 -0
- package/src/lib/clientIp.ts +41 -0
- package/src/lib/config.ts +30 -12
- package/src/lib/egress.ts +149 -0
- package/src/lib/email.ts +75 -7
- package/src/lib/ids.ts +2 -0
- package/src/lib/rateLimit.ts +16 -4
- package/src/modules/agent-tasks/dispatch.ts +9 -2
- package/src/modules/agent-tasks/index.ts +3 -1
- package/src/modules/auth/index.ts +12 -5
- package/src/modules/auth/repository.ts +56 -1
- package/src/modules/auth/routes.ts +34 -6
- package/src/modules/auth/schema.ts +17 -0
- package/src/modules/auth/service.ts +78 -5
- package/src/modules/deals/repository.ts +1 -0
- package/src/modules/decisions/repository.ts +8 -7
- package/src/modules/forms/index.ts +6 -1
- package/src/modules/forms/publicRoutes.ts +14 -4
- package/src/modules/forms/submission.ts +10 -0
- package/src/modules/opportunities/repository.ts +1 -0
- package/src/modules/partnerships/repository.ts +1 -0
- package/src/modules/raises/repository.ts +1 -0
- package/src/modules/rate-limit/middleware.ts +74 -11
- package/src/modules/webhooks/delivery.ts +10 -2
- package/src/modules/webhooks/index.ts +3 -1
- package/src/modules/workspace/idempotencyMiddleware.ts +17 -1
- package/src/modules/workspace/index.ts +4 -0
- package/src/modules/workspace/repository.ts +15 -8
- package/src/modules/workspace/routes.ts +0 -8
- package/src/modules/workspace/service.ts +53 -20
- package/src/modules/workspace/tools.ts +7 -21
- package/src/runtime/module.ts +25 -1
- package/src/runtime/registry.ts +59 -1
- package/src/testing/client.ts +22 -2
- package/src/testing/environment.ts +4 -0
- package/src/webBundle.ts +21 -7
|
@@ -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
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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 {
|
|
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:
|
|
100
|
+
account: accountBody(issued.account),
|
|
99
101
|
active_workspace_id: issued.activeWorkspaceId,
|
|
100
102
|
}
|
|
101
103
|
}
|
|
102
104
|
|
|
103
105
|
function accountBody(account: AccountView): Record<string, unknown> {
|
|
104
|
-
return {
|
|
106
|
+
return {
|
|
107
|
+
id: account.id,
|
|
108
|
+
email: account.email,
|
|
109
|
+
name: account.name,
|
|
110
|
+
email_verified: account.emailVerified,
|
|
111
|
+
}
|
|
105
112
|
}
|
|
106
113
|
|
|
107
114
|
function preferencesResponse(preferences: PreferenceValues): Record<string, unknown> {
|
|
@@ -131,7 +138,12 @@ export function mountAuthRoutes(router: Hono, dependencies: AuthRoutesDependenci
|
|
|
131
138
|
|
|
132
139
|
router.post('/auth/signup', async (context) => {
|
|
133
140
|
const body = await readBody(context, signUpBody)
|
|
134
|
-
const issued = await dependencies.service.signUp({
|
|
141
|
+
const issued = await dependencies.service.signUp({
|
|
142
|
+
email: body.email,
|
|
143
|
+
name: body.name,
|
|
144
|
+
password: body.password,
|
|
145
|
+
...describeClient(context),
|
|
146
|
+
})
|
|
135
147
|
|
|
136
148
|
writeSessionCookie(context, issued.sessionToken, dependencies.cookie)
|
|
137
149
|
|
|
@@ -156,12 +168,14 @@ export function mountAuthRoutes(router: Hono, dependencies: AuthRoutesDependenci
|
|
|
156
168
|
|
|
157
169
|
router.get('/auth/me', async (context) => {
|
|
158
170
|
const actor = await requireActor(context)
|
|
171
|
+
const account = await dependencies.service.getAccount(actor)
|
|
159
172
|
|
|
160
173
|
return context.json({
|
|
161
174
|
user_id: actor.userId,
|
|
162
175
|
session_id: actor.sessionId,
|
|
163
176
|
workspace_id: actor.workspaceId,
|
|
164
177
|
role: actor.role,
|
|
178
|
+
email_verified: account.emailVerified,
|
|
165
179
|
})
|
|
166
180
|
})
|
|
167
181
|
|
|
@@ -233,7 +247,7 @@ export function mountAuthRoutes(router: Hono, dependencies: AuthRoutesDependenci
|
|
|
233
247
|
*/
|
|
234
248
|
router.post('/auth/password-reset', async (context) => {
|
|
235
249
|
const body = await readBody(context, resetRequestBody)
|
|
236
|
-
await dependencies.service.requestPasswordReset(body.email
|
|
250
|
+
await dependencies.service.requestPasswordReset(body.email)
|
|
237
251
|
|
|
238
252
|
return context.body(null, 202)
|
|
239
253
|
})
|
|
@@ -244,4 +258,18 @@ export function mountAuthRoutes(router: Hono, dependencies: AuthRoutesDependenci
|
|
|
244
258
|
|
|
245
259
|
return context.body(null, 204)
|
|
246
260
|
})
|
|
261
|
+
|
|
262
|
+
/** Also what a "resend" button calls: a fresh token each time, and a no-op once verified. */
|
|
263
|
+
router.post('/auth/verify-email', async (context) => {
|
|
264
|
+
await dependencies.service.requestEmailVerification(await requireActor(context))
|
|
265
|
+
|
|
266
|
+
return context.body(null, 202)
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
router.post('/auth/verify-email/confirm', async (context) => {
|
|
270
|
+
const body = await readBody(context, verifyEmailConfirmBody)
|
|
271
|
+
await dependencies.service.confirmEmailVerification(body.token)
|
|
272
|
+
|
|
273
|
+
return context.body(null, 204)
|
|
274
|
+
})
|
|
247
275
|
}
|
|
@@ -19,6 +19,8 @@ export const users = pgTable('users', {
|
|
|
19
19
|
email: citext('email').notNull().unique(),
|
|
20
20
|
name: text('name').notNull(),
|
|
21
21
|
passwordHash: text('password_hash').notNull(),
|
|
22
|
+
/** Null until the address is verified, either by confirming a token or by accepting a workspace invite. */
|
|
23
|
+
emailVerifiedAt: moment('email_verified_at'),
|
|
22
24
|
createdAt: createdAt(),
|
|
23
25
|
updatedAt: updatedAt(),
|
|
24
26
|
})
|
|
@@ -75,3 +77,18 @@ export const passwordResetTokens = pgTable('password_reset_tokens', {
|
|
|
75
77
|
usedAt: moment('used_at'),
|
|
76
78
|
createdAt: createdAt(),
|
|
77
79
|
})
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* One-shot tokens for the sign-up verification link. Same shape and the same
|
|
83
|
+
* reasoning as `passwordResetTokens`: hashed, marked used rather than deleted.
|
|
84
|
+
*/
|
|
85
|
+
export const emailVerificationTokens = pgTable('email_verification_tokens', {
|
|
86
|
+
id: primaryId(),
|
|
87
|
+
userId: text('user_id')
|
|
88
|
+
.notNull()
|
|
89
|
+
.references(() => users.id, { onDelete: 'cascade' }),
|
|
90
|
+
tokenHash: text('token_hash').notNull().unique(),
|
|
91
|
+
expiresAt: moment('expires_at').notNull(),
|
|
92
|
+
usedAt: moment('used_at'),
|
|
93
|
+
createdAt: createdAt(),
|
|
94
|
+
})
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { APP_LINK_PATHS, buildAppLink } from '../../lib/appUrl.ts'
|
|
1
2
|
import type { EmailSender } from '../../lib/email.ts'
|
|
2
3
|
import { AppError } from '../../lib/errors.ts'
|
|
3
4
|
import type { IdFactory } from '../../lib/ids.ts'
|
|
@@ -22,6 +23,8 @@ import * as repository from './repository.ts'
|
|
|
22
23
|
|
|
23
24
|
const SESSION_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000
|
|
24
25
|
const RESET_TOKEN_LIFETIME_MS = 60 * 60 * 1000
|
|
26
|
+
/** Longer than a reset token: there is no stolen-account urgency, and an inbox is checked on its own time. */
|
|
27
|
+
const EMAIL_VERIFICATION_TOKEN_LIFETIME_MS = 24 * 60 * 60 * 1000
|
|
25
28
|
|
|
26
29
|
export interface AuthDependencies {
|
|
27
30
|
readonly db: Database
|
|
@@ -29,6 +32,8 @@ export interface AuthDependencies {
|
|
|
29
32
|
readonly email: EmailSender
|
|
30
33
|
readonly createId: IdFactory
|
|
31
34
|
readonly now: () => Date
|
|
35
|
+
/** The deployment's own base URL. Every emailed link is built from it. */
|
|
36
|
+
readonly appBaseUrl: string
|
|
32
37
|
/** Injected only so tests can pin tokens. Production uses the crypto default. */
|
|
33
38
|
readonly newToken?: () => string
|
|
34
39
|
}
|
|
@@ -37,6 +42,7 @@ export interface AccountView {
|
|
|
37
42
|
readonly id: string
|
|
38
43
|
readonly email: string
|
|
39
44
|
readonly name: string
|
|
45
|
+
readonly emailVerified: boolean
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
/** A session plus the plaintext token, which exists only in this return value. */
|
|
@@ -79,7 +85,7 @@ export interface LogInInput {
|
|
|
79
85
|
}
|
|
80
86
|
|
|
81
87
|
function toAccountView(user: repository.UserRecord): AccountView {
|
|
82
|
-
return { id: user.id, email: user.email, name: user.name }
|
|
88
|
+
return { id: user.id, email: user.email, name: user.name, emailVerified: user.emailVerifiedAt !== null }
|
|
83
89
|
}
|
|
84
90
|
|
|
85
91
|
function toPreferenceValues(record: repository.UserPreferencesRecord): PreferenceValues {
|
|
@@ -129,9 +135,12 @@ export interface AuthService {
|
|
|
129
135
|
listSessions(actor: SessionActor): Promise<readonly SessionView[]>
|
|
130
136
|
revokeSession(actor: SessionActor, sessionId: string): Promise<void>
|
|
131
137
|
/** Resolves whether or not the address is registered. */
|
|
132
|
-
requestPasswordReset(email: string
|
|
138
|
+
requestPasswordReset(email: string): Promise<void>
|
|
133
139
|
confirmPasswordReset(token: string, newPassword: string): Promise<void>
|
|
134
140
|
changePassword(actor: SessionActor, currentPassword: string, newPassword: string): Promise<void>
|
|
141
|
+
/** Issues a fresh verification token and emails it. A no-op once already verified. */
|
|
142
|
+
requestEmailVerification(actor: SessionActor): Promise<void>
|
|
143
|
+
confirmEmailVerification(token: string): Promise<void>
|
|
135
144
|
}
|
|
136
145
|
|
|
137
146
|
export function createAuthService(dependencies: AuthDependencies): AuthService {
|
|
@@ -181,6 +190,16 @@ export function createAuthService(dependencies: AuthDependencies): AuthService {
|
|
|
181
190
|
}
|
|
182
191
|
}
|
|
183
192
|
|
|
193
|
+
function sendVerificationEmail(to: string, token: string): Promise<void> {
|
|
194
|
+
const link = buildAppLink(dependencies.appBaseUrl, APP_LINK_PATHS.verifyEmail, token)
|
|
195
|
+
|
|
196
|
+
return dependencies.email.send({
|
|
197
|
+
to,
|
|
198
|
+
subject: 'Verify your Kelpie email address',
|
|
199
|
+
body: `Confirm this address to finish setting up your account:\n\n${link}\n\nThe link expires in 24 hours.`,
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
|
|
184
203
|
return {
|
|
185
204
|
/** Creates the account only. The first workspace comes from onboarding. */
|
|
186
205
|
async signUp(input: SignUpInput): Promise<IssuedSession> {
|
|
@@ -192,8 +211,10 @@ export function createAuthService(dependencies: AuthDependencies): AuthService {
|
|
|
192
211
|
|
|
193
212
|
const email = normaliseEmail(input.email)
|
|
194
213
|
const passwordHash = await hashPassword(input.password)
|
|
214
|
+
const now = dependencies.now()
|
|
215
|
+
const verificationToken = newToken()
|
|
195
216
|
|
|
196
|
-
|
|
217
|
+
const issued = await dependencies.transaction(async ({ tx }) => {
|
|
197
218
|
let user: repository.UserRecord
|
|
198
219
|
|
|
199
220
|
try {
|
|
@@ -215,8 +236,20 @@ export function createAuthService(dependencies: AuthDependencies): AuthService {
|
|
|
215
236
|
throw error
|
|
216
237
|
}
|
|
217
238
|
|
|
239
|
+
await repository.insertEmailVerificationToken(tx, {
|
|
240
|
+
id: dependencies.createId('emailVerificationToken'),
|
|
241
|
+
userId: user.id,
|
|
242
|
+
tokenHash: hashToken(verificationToken),
|
|
243
|
+
expiresAt: new Date(now.getTime() + EMAIL_VERIFICATION_TOKEN_LIFETIME_MS),
|
|
244
|
+
})
|
|
245
|
+
|
|
218
246
|
return issueSession(tx, user, input.device, input.location)
|
|
219
247
|
})
|
|
248
|
+
|
|
249
|
+
// Sent after commit, so a rolled-back signup never emails a token.
|
|
250
|
+
await sendVerificationEmail(email, verificationToken)
|
|
251
|
+
|
|
252
|
+
return issued
|
|
220
253
|
},
|
|
221
254
|
|
|
222
255
|
async logIn(input: LogInInput): Promise<IssuedSession> {
|
|
@@ -367,7 +400,7 @@ export function createAuthService(dependencies: AuthDependencies): AuthService {
|
|
|
367
400
|
* Always resolves, whether or not the address is registered. Telling a
|
|
368
401
|
* stranger which addresses have accounts is the whole attack.
|
|
369
402
|
*/
|
|
370
|
-
async requestPasswordReset(email: string
|
|
403
|
+
async requestPasswordReset(email: string): Promise<void> {
|
|
371
404
|
const user = await repository.findUserByEmail(dependencies.db, normaliseEmail(email))
|
|
372
405
|
|
|
373
406
|
if (user === undefined) {
|
|
@@ -384,10 +417,12 @@ export function createAuthService(dependencies: AuthDependencies): AuthService {
|
|
|
384
417
|
expiresAt: new Date(now.getTime() + RESET_TOKEN_LIFETIME_MS),
|
|
385
418
|
})
|
|
386
419
|
|
|
420
|
+
const link = buildAppLink(dependencies.appBaseUrl, APP_LINK_PATHS.passwordReset, token)
|
|
421
|
+
|
|
387
422
|
await dependencies.email.send({
|
|
388
423
|
to: user.email,
|
|
389
424
|
subject: 'Reset your Kelpie password',
|
|
390
|
-
body: `Use this link within the hour to choose a new password:\n\n${
|
|
425
|
+
body: `Use this link within the hour to choose a new password:\n\n${link}\n\nIf you did not ask for this, ignore it.`,
|
|
391
426
|
})
|
|
392
427
|
},
|
|
393
428
|
|
|
@@ -437,5 +472,43 @@ export function createAuthService(dependencies: AuthDependencies): AuthService {
|
|
|
437
472
|
await repository.deleteOtherSessionsForUser(tx, user.id, actor.sessionId)
|
|
438
473
|
})
|
|
439
474
|
},
|
|
475
|
+
|
|
476
|
+
async requestEmailVerification(actor: SessionActor): Promise<void> {
|
|
477
|
+
const user = await requireUser(actor.userId)
|
|
478
|
+
|
|
479
|
+
if (user.emailVerifiedAt !== null) {
|
|
480
|
+
return
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const now = dependencies.now()
|
|
484
|
+
const token = newToken()
|
|
485
|
+
|
|
486
|
+
await repository.insertEmailVerificationToken(dependencies.db, {
|
|
487
|
+
id: dependencies.createId('emailVerificationToken'),
|
|
488
|
+
userId: user.id,
|
|
489
|
+
tokenHash: hashToken(token),
|
|
490
|
+
expiresAt: new Date(now.getTime() + EMAIL_VERIFICATION_TOKEN_LIFETIME_MS),
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
await sendVerificationEmail(user.email, token)
|
|
494
|
+
},
|
|
495
|
+
|
|
496
|
+
async confirmEmailVerification(token: string): Promise<void> {
|
|
497
|
+
const now = dependencies.now()
|
|
498
|
+
const record = await repository.findUsableEmailVerificationToken(
|
|
499
|
+
dependencies.db,
|
|
500
|
+
hashToken(token),
|
|
501
|
+
now,
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
if (record === undefined) {
|
|
505
|
+
throw AppError.unauthorized('That verification link is invalid or has expired')
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
await dependencies.transaction(async ({ tx }) => {
|
|
509
|
+
await repository.markEmailVerificationTokenUsed(tx, record.id, now)
|
|
510
|
+
await repository.markEmailVerified(tx, record.userId, now)
|
|
511
|
+
})
|
|
512
|
+
},
|
|
440
513
|
}
|
|
441
514
|
}
|
|
@@ -49,12 +49,12 @@ export interface DecisionFilters {
|
|
|
49
49
|
* away, so a candidate-targeted decision matches on body and rationale only.
|
|
50
50
|
*/
|
|
51
51
|
const NAMED_TARGET_TABLES = [
|
|
52
|
-
{ table: people, id: people.id, name: people.name },
|
|
53
|
-
{ table: companies, id: companies.id, name: companies.name },
|
|
54
|
-
{ table: deals, id: deals.id, name: deals.name },
|
|
55
|
-
{ table: opportunities, id: opportunities.id, name: opportunities.name },
|
|
56
|
-
{ table: partnerships, id: partnerships.id, name: partnerships.name },
|
|
57
|
-
{ table: raises, id: raises.id, name: raises.name },
|
|
52
|
+
{ table: people, id: people.id, workspaceId: people.workspaceId, name: people.name },
|
|
53
|
+
{ table: companies, id: companies.id, workspaceId: companies.workspaceId, name: companies.name },
|
|
54
|
+
{ table: deals, id: deals.id, workspaceId: deals.workspaceId, name: deals.name },
|
|
55
|
+
{ table: opportunities, id: opportunities.id, workspaceId: opportunities.workspaceId, name: opportunities.name },
|
|
56
|
+
{ table: partnerships, id: partnerships.id, workspaceId: partnerships.workspaceId, name: partnerships.name },
|
|
57
|
+
{ table: raises, id: raises.id, workspaceId: raises.workspaceId, name: raises.name },
|
|
58
58
|
]
|
|
59
59
|
|
|
60
60
|
/**
|
|
@@ -64,10 +64,11 @@ const NAMED_TARGET_TABLES = [
|
|
|
64
64
|
*/
|
|
65
65
|
function targetNameMatches(pattern: string): SQL[] {
|
|
66
66
|
return NAMED_TARGET_TABLES.map(
|
|
67
|
-
({ table, id, name }) => sql`exists (
|
|
67
|
+
({ table, id, workspaceId, name }) => sql`exists (
|
|
68
68
|
select 1
|
|
69
69
|
from ${table}
|
|
70
70
|
where ${id} = ${decisions.targetId}
|
|
71
|
+
and ${workspaceId} = ${decisions.workspaceId}
|
|
71
72
|
and ${name} ilike ${pattern}
|
|
72
73
|
)`,
|
|
73
74
|
)
|
|
@@ -41,6 +41,7 @@ export function createFormsModule(migrationsDirectory: string): KelpieModule {
|
|
|
41
41
|
createId: context.createId,
|
|
42
42
|
now: context.now,
|
|
43
43
|
}),
|
|
44
|
+
entitlements: context.entitlements,
|
|
44
45
|
})
|
|
45
46
|
|
|
46
47
|
context.schema(schema, migrationsDirectory)
|
|
@@ -50,7 +51,11 @@ export function createFormsModule(migrationsDirectory: string): KelpieModule {
|
|
|
50
51
|
})
|
|
51
52
|
|
|
52
53
|
context.publicRoutes((router) => {
|
|
53
|
-
mountPublicFormRoutes(router, {
|
|
54
|
+
mountPublicFormRoutes(router, {
|
|
55
|
+
db: context.db,
|
|
56
|
+
submissions,
|
|
57
|
+
entitlements: context.entitlements,
|
|
58
|
+
})
|
|
54
59
|
})
|
|
55
60
|
|
|
56
61
|
registerFormsTools(context.mcp, service)
|
|
@@ -4,6 +4,9 @@ import { z } from 'zod'
|
|
|
4
4
|
import type { Database } from '../../lib/database.ts'
|
|
5
5
|
import { AppError } from '../../lib/errors.ts'
|
|
6
6
|
import { PUBLIC_ROUTE_PREFIX, readJsonBody, requestOrigin } from '../../lib/http.ts'
|
|
7
|
+
import { requireCapability } from '../../runtime/entitlements.ts'
|
|
8
|
+
import type { EntitlementRegistry } from '../../runtime/entitlements.ts'
|
|
9
|
+
import { moduleCapabilityName } from '../../runtime/moduleConfig.ts'
|
|
7
10
|
import { embedContentSecurityPolicy, renderEmbedPage } from './embed.ts'
|
|
8
11
|
import * as repository from './repository.ts'
|
|
9
12
|
import type { FormSubmitService, SubmitOutcome } from './submission.ts'
|
|
@@ -28,17 +31,20 @@ export interface PublicFormRoutesDependencies {
|
|
|
28
31
|
/** Injected so a test can pin the value the CSP and the tags share. */
|
|
29
32
|
readonly generateNonce?: () => string
|
|
30
33
|
readonly submissions: FormSubmitService
|
|
34
|
+
/** The embed of a form in a workspace that has turned the module off is refused. */
|
|
35
|
+
readonly entitlements: EntitlementRegistry
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
function submitResponse(outcome: SubmitOutcome): Record<string, unknown> {
|
|
39
|
+
// The upserted record ids stay off this response on purpose. The caller is an
|
|
40
|
+
// unauthenticated website, and a Kelpie id is a ULID whose timestamp would tell
|
|
41
|
+
// that caller whether the person or company it named was already in the CRM.
|
|
42
|
+
// The service still computes them for its own events and activities; they are
|
|
43
|
+
// simply not on the wire here.
|
|
34
44
|
return {
|
|
35
45
|
id: outcome.submissionId,
|
|
36
46
|
form_id: outcome.formId,
|
|
37
47
|
submitted_at: outcome.submittedAt.toISOString(),
|
|
38
|
-
person_id: outcome.personId,
|
|
39
|
-
company_id: outcome.companyId,
|
|
40
|
-
position_id: outcome.positionId,
|
|
41
|
-
deal_id: outcome.dealId,
|
|
42
48
|
// Echoed so an embed can render the confirmation without a second request
|
|
43
49
|
// for a form definition it has no other reason to fetch.
|
|
44
50
|
thank_you_message: outcome.thankYouMessage,
|
|
@@ -73,6 +79,10 @@ export function mountPublicFormRoutes(
|
|
|
73
79
|
throw AppError.notFound('Form not found')
|
|
74
80
|
}
|
|
75
81
|
|
|
82
|
+
// Ungated by the runtime, like the submit route: a workspace with the forms
|
|
83
|
+
// module off does not serve its embed either.
|
|
84
|
+
await requireCapability(dependencies.entitlements, form.workspaceId, moduleCapabilityName('forms'))
|
|
85
|
+
|
|
76
86
|
const nonce = generateNonce()
|
|
77
87
|
const page = renderEmbedPage({
|
|
78
88
|
form,
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { Database } from '../../lib/database.ts'
|
|
2
2
|
import { AppError } from '../../lib/errors.ts'
|
|
3
3
|
import type { IdFactory } from '../../lib/ids.ts'
|
|
4
|
+
import { requireCapability } from '../../runtime/entitlements.ts'
|
|
5
|
+
import type { EntitlementRegistry } from '../../runtime/entitlements.ts'
|
|
6
|
+
import { moduleCapabilityName } from '../../runtime/moduleConfig.ts'
|
|
4
7
|
import type { BufferedEvents, Transaction, TransactionScope } from '../../runtime/transaction.ts'
|
|
5
8
|
import type { ActivityRecorder, SystemActor } from '../activities/recorder.ts'
|
|
6
9
|
import { describeCreationVia, describeFormSubmission } from '../activities/wording.ts'
|
|
@@ -85,6 +88,8 @@ export interface SubmissionDependencies {
|
|
|
85
88
|
readonly createId: IdFactory
|
|
86
89
|
readonly now: () => Date
|
|
87
90
|
readonly recordActivity: ActivityRecorder
|
|
91
|
+
/** A submit into a workspace that has turned the forms module off is refused. */
|
|
92
|
+
readonly entitlements: EntitlementRegistry
|
|
88
93
|
}
|
|
89
94
|
|
|
90
95
|
/** What the submit created or matched. Each id is null when the rule did not apply. */
|
|
@@ -394,6 +399,11 @@ export function createFormSubmitService(dependencies: SubmissionDependencies): F
|
|
|
394
399
|
throw AppError.notFound('Form not found')
|
|
395
400
|
}
|
|
396
401
|
|
|
402
|
+
// The public surface is ungated by the runtime (`runtime/registry.ts` gates
|
|
403
|
+
// only credentialled routes), so a workspace that has turned the forms
|
|
404
|
+
// module off is refused here, the same 403 the REST surface gives.
|
|
405
|
+
await requireCapability(dependencies.entitlements, form.workspaceId, moduleCapabilityName('forms'))
|
|
406
|
+
|
|
397
407
|
if (form.status === 'paused') {
|
|
398
408
|
throw AppError.conflict('This form is not accepting submissions')
|
|
399
409
|
}
|