@kelpie/server 0.4.1 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +27 -0
- package/dist/app.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/appUrl.d.ts +27 -0
- package/dist/lib/appUrl.d.ts.map +1 -0
- package/dist/lib/appUrl.js +53 -0
- package/dist/lib/appUrl.js.map +1 -0
- package/dist/lib/clientIp.d.ts +24 -0
- package/dist/lib/clientIp.d.ts.map +1 -0
- package/dist/lib/clientIp.js +34 -0
- package/dist/lib/clientIp.js.map +1 -0
- package/dist/lib/config.d.ts +6 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +2 -0
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/egress.d.ts +44 -0
- package/dist/lib/egress.d.ts.map +1 -0
- package/dist/lib/egress.js +111 -0
- package/dist/lib/egress.js.map +1 -0
- package/dist/lib/ids.d.ts +2 -0
- package/dist/lib/ids.d.ts.map +1 -1
- package/dist/lib/ids.js +2 -0
- package/dist/lib/ids.js.map +1 -1
- package/dist/lib/rateLimit.d.ts +12 -4
- package/dist/lib/rateLimit.d.ts.map +1 -1
- package/dist/lib/rateLimit.js +6 -0
- package/dist/lib/rateLimit.js.map +1 -1
- package/dist/modules/agent-tasks/dispatch.d.ts +4 -2
- package/dist/modules/agent-tasks/dispatch.d.ts.map +1 -1
- package/dist/modules/agent-tasks/dispatch.js +4 -2
- package/dist/modules/agent-tasks/dispatch.js.map +1 -1
- package/dist/modules/agent-tasks/index.d.ts.map +1 -1
- package/dist/modules/agent-tasks/index.js +3 -1
- package/dist/modules/agent-tasks/index.js.map +1 -1
- package/dist/modules/auth/index.d.ts.map +1 -1
- package/dist/modules/auth/index.js +10 -3
- package/dist/modules/auth/index.js.map +1 -1
- package/dist/modules/auth/repository.d.ts +13 -1
- package/dist/modules/auth/repository.d.ts.map +1 -1
- package/dist/modules/auth/repository.js +32 -1
- package/dist/modules/auth/repository.js.map +1 -1
- package/dist/modules/auth/routes.d.ts.map +1 -1
- package/dist/modules/auth/routes.js +29 -6
- package/dist/modules/auth/routes.js.map +1 -1
- package/dist/modules/auth/schema.d.ts +130 -0
- package/dist/modules/auth/schema.d.ts.map +1 -1
- package/dist/modules/auth/schema.js +16 -0
- package/dist/modules/auth/schema.js.map +1 -1
- package/dist/modules/auth/service.d.ts +7 -1
- package/dist/modules/auth/service.d.ts.map +1 -1
- package/dist/modules/auth/service.js +53 -4
- package/dist/modules/auth/service.js.map +1 -1
- package/dist/modules/deals/repository.d.ts.map +1 -1
- package/dist/modules/deals/repository.js +1 -0
- package/dist/modules/deals/repository.js.map +1 -1
- package/dist/modules/decisions/repository.d.ts.map +1 -1
- package/dist/modules/decisions/repository.js +8 -7
- package/dist/modules/decisions/repository.js.map +1 -1
- package/dist/modules/forms/index.d.ts.map +1 -1
- package/dist/modules/forms/index.js +6 -1
- package/dist/modules/forms/index.js.map +1 -1
- package/dist/modules/forms/publicRoutes.d.ts +3 -0
- package/dist/modules/forms/publicRoutes.d.ts.map +1 -1
- package/dist/modules/forms/publicRoutes.js +10 -4
- package/dist/modules/forms/publicRoutes.js.map +1 -1
- package/dist/modules/forms/submission.d.ts +3 -0
- package/dist/modules/forms/submission.d.ts.map +1 -1
- package/dist/modules/forms/submission.js +6 -0
- package/dist/modules/forms/submission.js.map +1 -1
- package/dist/modules/opportunities/repository.d.ts.map +1 -1
- package/dist/modules/opportunities/repository.js +1 -0
- package/dist/modules/opportunities/repository.js.map +1 -1
- package/dist/modules/partnerships/repository.d.ts.map +1 -1
- package/dist/modules/partnerships/repository.js +1 -0
- package/dist/modules/partnerships/repository.js.map +1 -1
- package/dist/modules/raises/repository.d.ts.map +1 -1
- package/dist/modules/raises/repository.js +1 -0
- package/dist/modules/raises/repository.js.map +1 -1
- package/dist/modules/rate-limit/middleware.d.ts +4 -4
- package/dist/modules/rate-limit/middleware.d.ts.map +1 -1
- package/dist/modules/rate-limit/middleware.js +57 -11
- package/dist/modules/rate-limit/middleware.js.map +1 -1
- package/dist/modules/webhooks/delivery.d.ts +5 -2
- package/dist/modules/webhooks/delivery.d.ts.map +1 -1
- package/dist/modules/webhooks/delivery.js +5 -2
- package/dist/modules/webhooks/delivery.js.map +1 -1
- package/dist/modules/webhooks/index.d.ts.map +1 -1
- package/dist/modules/webhooks/index.js +3 -1
- package/dist/modules/webhooks/index.js.map +1 -1
- package/dist/modules/workspace/capabilities.d.ts +10 -1
- package/dist/modules/workspace/capabilities.d.ts.map +1 -1
- package/dist/modules/workspace/capabilities.js +13 -0
- package/dist/modules/workspace/capabilities.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 +5 -1
- 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/modules/workspace/workspaceAccessMiddleware.d.ts +8 -0
- package/dist/modules/workspace/workspaceAccessMiddleware.d.ts.map +1 -0
- package/dist/modules/workspace/workspaceAccessMiddleware.js +58 -0
- package/dist/modules/workspace/workspaceAccessMiddleware.js.map +1 -0
- package/dist/operator.d.ts +40 -0
- package/dist/operator.d.ts.map +1 -0
- package/dist/operator.js +50 -0
- package/dist/operator.js.map +1 -0
- package/dist/runtime/module.d.ts +25 -1
- package/dist/runtime/module.d.ts.map +1 -1
- package/dist/runtime/registry.d.ts +16 -1
- package/dist/runtime/registry.d.ts.map +1 -1
- package/dist/runtime/registry.js +34 -0
- package/dist/runtime/registry.js.map +1 -1
- package/dist/testing/client.d.ts +3 -1
- package/dist/testing/client.d.ts.map +1 -1
- package/dist/testing/client.js +19 -2
- package/dist/testing/client.js.map +1 -1
- package/dist/testing/environment.d.ts +2 -0
- package/dist/testing/environment.d.ts.map +1 -1
- package/dist/testing/environment.js +3 -0
- package/dist/testing/environment.js.map +1 -1
- package/dist/webBundle.d.ts +8 -0
- package/dist/webBundle.d.ts.map +1 -1
- package/dist/webBundle.js +9 -7
- package/dist/webBundle.js.map +1 -1
- package/migrations/0018_hard_mystique.sql +12 -0
- package/migrations/meta/0018_snapshot.json +5113 -0
- package/migrations/meta/_journal.json +8 -1
- package/package.json +2 -2
- package/src/app.ts +32 -0
- package/src/index.ts +9 -1
- package/src/lib/appUrl.ts +64 -0
- package/src/lib/clientIp.ts +41 -0
- package/src/lib/config.ts +8 -0
- package/src/lib/egress.ts +149 -0
- package/src/lib/ids.ts +2 -0
- package/src/lib/rateLimit.ts +16 -4
- package/src/modules/agent-tasks/dispatch.ts +9 -2
- package/src/modules/agent-tasks/index.ts +3 -1
- package/src/modules/auth/index.ts +12 -5
- package/src/modules/auth/repository.ts +56 -1
- package/src/modules/auth/routes.ts +34 -6
- package/src/modules/auth/schema.ts +17 -0
- package/src/modules/auth/service.ts +78 -5
- package/src/modules/deals/repository.ts +1 -0
- package/src/modules/decisions/repository.ts +8 -7
- package/src/modules/forms/index.ts +6 -1
- package/src/modules/forms/publicRoutes.ts +14 -4
- package/src/modules/forms/submission.ts +10 -0
- package/src/modules/opportunities/repository.ts +1 -0
- package/src/modules/partnerships/repository.ts +1 -0
- package/src/modules/raises/repository.ts +1 -0
- package/src/modules/rate-limit/middleware.ts +74 -11
- package/src/modules/webhooks/delivery.ts +10 -2
- package/src/modules/webhooks/index.ts +3 -1
- package/src/modules/workspace/capabilities.ts +15 -1
- package/src/modules/workspace/idempotencyMiddleware.ts +17 -1
- package/src/modules/workspace/index.ts +6 -1
- 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/modules/workspace/workspaceAccessMiddleware.ts +76 -0
- 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
|
@@ -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,6 +1,7 @@
|
|
|
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
|
-
import { SEATS_LIMIT } from './capabilities.ts'
|
|
4
|
+
import { SEATS_LIMIT, WORKSPACE_ACCESS } from './capabilities.ts'
|
|
4
5
|
import * as repository from './repository.ts'
|
|
5
6
|
import { mountWorkspaceRoutes } from './routes.ts'
|
|
6
7
|
import * as schema from './schema.ts'
|
|
@@ -20,7 +21,10 @@ 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)
|
|
27
|
+
context.entitlements.declare(WORKSPACE_ACCESS)
|
|
24
28
|
|
|
25
29
|
// Answers `module.<id>` for whatever a config override left undecided
|
|
26
30
|
// (`runtime/registry.ts` registers that provider first, ahead of this
|
|
@@ -44,6 +48,7 @@ export function createWorkspaceModule(migrationsDirectory: string): KelpieModule
|
|
|
44
48
|
createId: context.createId,
|
|
45
49
|
now: context.now,
|
|
46
50
|
entitlements: context.entitlements,
|
|
51
|
+
appBaseUrl: config.APP_BASE_URL,
|
|
47
52
|
toggleableModuleIds: context.moduleCatalog
|
|
48
53
|
.filter((entry) => !entry.structural)
|
|
49
54
|
.map((entry) => entry.id),
|
|
@@ -132,21 +132,28 @@ export async function deleteMember(db: Queryable, id: string): Promise<void> {
|
|
|
132
132
|
*/
|
|
133
133
|
export async function countMemberReferences(
|
|
134
134
|
db: Queryable,
|
|
135
|
+
workspaceId: string,
|
|
135
136
|
memberId: string,
|
|
136
137
|
): Promise<readonly { readonly type: string; readonly count: number }[]> {
|
|
137
138
|
const sources = [
|
|
138
|
-
{ type: 'deal', table: deals, column: deals.ownerId },
|
|
139
|
-
{ type: 'opportunity', table: opportunities, column: opportunities.ownerId },
|
|
140
|
-
{ type: 'partnership', table: partnerships, column: partnerships.ownerId },
|
|
141
|
-
{ type: 'raise', table: raises, column: raises.ownerId },
|
|
142
|
-
{ type: 'plan_item', table: planItems, column: planItems.ownerId },
|
|
143
|
-
{ type: 'decision', table: decisions, column: decisions.ownerId },
|
|
144
|
-
{ type: 'note', table: notes, column: notes.authorId },
|
|
139
|
+
{ type: 'deal', table: deals, column: deals.ownerId, workspaceId: deals.workspaceId },
|
|
140
|
+
{ type: 'opportunity', table: opportunities, column: opportunities.ownerId, workspaceId: opportunities.workspaceId },
|
|
141
|
+
{ type: 'partnership', table: partnerships, column: partnerships.ownerId, workspaceId: partnerships.workspaceId },
|
|
142
|
+
{ type: 'raise', table: raises, column: raises.ownerId, workspaceId: raises.workspaceId },
|
|
143
|
+
{ type: 'plan_item', table: planItems, column: planItems.ownerId, workspaceId: planItems.workspaceId },
|
|
144
|
+
{ type: 'decision', table: decisions, column: decisions.ownerId, workspaceId: decisions.workspaceId },
|
|
145
|
+
{ type: 'note', table: notes, column: notes.authorId, workspaceId: notes.workspaceId },
|
|
145
146
|
] as const
|
|
146
147
|
|
|
147
148
|
const counted = await Promise.all(
|
|
148
149
|
sources.map(async (source) => {
|
|
149
|
-
|
|
150
|
+
// A member id is globally unique, so the workspace term is defence in
|
|
151
|
+
// depth rather than the thing that scopes the count. It keeps the query
|
|
152
|
+
// safe on its own instead of by that invariant.
|
|
153
|
+
const [row] = await db
|
|
154
|
+
.select({ total: count() })
|
|
155
|
+
.from(source.table)
|
|
156
|
+
.where(and(eq(source.column, memberId), eq(source.workspaceId, workspaceId)))
|
|
150
157
|
|
|
151
158
|
return { type: source.type, count: row?.total ?? 0 }
|
|
152
159
|
}),
|
|
@@ -34,11 +34,6 @@ export const updateBody = z
|
|
|
34
34
|
export const inviteBody = z.object({
|
|
35
35
|
email: z.string().min(1),
|
|
36
36
|
role: z.enum(INVITABLE_ROLES),
|
|
37
|
-
invite_url_template: z.string().min(1).includes('{token}'),
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
export const resendBody = z.object({
|
|
41
|
-
invite_url_template: z.string().min(1).includes('{token}'),
|
|
42
37
|
})
|
|
43
38
|
|
|
44
39
|
export const memberRoleBody = z.object({ role: z.enum(MEMBER_ROLES) })
|
|
@@ -187,7 +182,6 @@ export function mountWorkspaceRoutes(router: Hono, dependencies: WorkspaceRoutes
|
|
|
187
182
|
context.req.param('id'),
|
|
188
183
|
body.email,
|
|
189
184
|
body.role,
|
|
190
|
-
body.invite_url_template,
|
|
191
185
|
)
|
|
192
186
|
|
|
193
187
|
return context.json(inviteResponse(invite), 201)
|
|
@@ -203,12 +197,10 @@ export function mountWorkspaceRoutes(router: Hono, dependencies: WorkspaceRoutes
|
|
|
203
197
|
})
|
|
204
198
|
|
|
205
199
|
router.post('/workspaces/:id/invites/:inviteId/resend', async (context) => {
|
|
206
|
-
const body = await readBody(context, resendBody)
|
|
207
200
|
const invite = await dependencies.service.resendInvite(
|
|
208
201
|
await requireActor(context),
|
|
209
202
|
context.req.param('id'),
|
|
210
203
|
context.req.param('inviteId'),
|
|
211
|
-
body.invite_url_template,
|
|
212
204
|
)
|
|
213
205
|
|
|
214
206
|
return context.json(inviteResponse(invite))
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { APP_LINK_PATHS, buildAppLink } from '../../lib/appUrl.ts'
|
|
1
2
|
import { UNIQUE_VIOLATION, isReferenceViolation, postgresErrorCode } from '../../lib/database.ts'
|
|
2
3
|
import type { Database } from '../../lib/database.ts'
|
|
3
4
|
import type { EmailSender } from '../../lib/email.ts'
|
|
@@ -36,6 +37,8 @@ export interface WorkspaceDependencies {
|
|
|
36
37
|
readonly createId: IdFactory
|
|
37
38
|
readonly now: () => Date
|
|
38
39
|
readonly entitlements: EntitlementRegistry
|
|
40
|
+
/** The deployment's own base URL. Every invitation link is built from it. */
|
|
41
|
+
readonly appBaseUrl: string
|
|
39
42
|
readonly newToken?: () => string
|
|
40
43
|
/** Every non-structural module id (`runtime/module.ts`'s `moduleCatalog`), in assembly order. */
|
|
41
44
|
readonly toggleableModuleIds: readonly string[]
|
|
@@ -117,10 +120,10 @@ export interface WorkspaceService {
|
|
|
117
120
|
/** Changes a member's role, or transfers ownership when `role` is `owner`. */
|
|
118
121
|
setMemberRole(actor: Actor, workspaceId: string, memberId: string, role: MemberRole): Promise<MemberView>
|
|
119
122
|
removeMember(actor: Actor, workspaceId: string, memberId: string): Promise<void>
|
|
120
|
-
invite(actor: Actor, workspaceId: string, email: string, role: InvitableRole
|
|
123
|
+
invite(actor: Actor, workspaceId: string, email: string, role: InvitableRole): Promise<InviteView>
|
|
121
124
|
listInvites(actor: Actor, workspaceId: string): Promise<readonly InviteView[]>
|
|
122
125
|
/** Issues a fresh token and expiry for an invitation, and emails it again. */
|
|
123
|
-
resendInvite(actor: Actor, workspaceId: string, inviteId: string
|
|
126
|
+
resendInvite(actor: Actor, workspaceId: string, inviteId: string): Promise<InviteView>
|
|
124
127
|
revokeInvite(actor: Actor, workspaceId: string, inviteId: string): Promise<void>
|
|
125
128
|
/** Joins the invited workspace as the calling account. */
|
|
126
129
|
acceptInvite(actor: SessionActor, token: string): Promise<WorkspaceView>
|
|
@@ -292,6 +295,26 @@ export function createWorkspaceService(dependencies: WorkspaceDependencies): Wor
|
|
|
292
295
|
}
|
|
293
296
|
}
|
|
294
297
|
|
|
298
|
+
/**
|
|
299
|
+
* Refuses an actor whose account has not verified its email address.
|
|
300
|
+
*
|
|
301
|
+
* Creating a workspace is the one action that turns a bare account into
|
|
302
|
+
* something with real access (`schema.md`), so it is where the gate lives:
|
|
303
|
+
* every CRM endpoint already refuses an actor with no workspace, which means
|
|
304
|
+
* blocking this one call is enough to block the rest.
|
|
305
|
+
*/
|
|
306
|
+
async function requireVerifiedEmail(actor: SessionActor): Promise<void> {
|
|
307
|
+
const user = await authRepository.findUserById(dependencies.db, actor.userId)
|
|
308
|
+
|
|
309
|
+
if (user === undefined) {
|
|
310
|
+
throw new Error(`Session ${actor.sessionId} outlived the user row behind it`)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (user.emailVerifiedAt === null) {
|
|
314
|
+
throw new AppError('forbidden', 'Verify your email before creating a workspace')
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
295
318
|
/**
|
|
296
319
|
* The membership being acted on.
|
|
297
320
|
*
|
|
@@ -309,11 +332,13 @@ export function createWorkspaceService(dependencies: WorkspaceDependencies): Wor
|
|
|
309
332
|
return target
|
|
310
333
|
}
|
|
311
334
|
|
|
312
|
-
function sendInviteEmail(to: string, token: string
|
|
335
|
+
function sendInviteEmail(to: string, token: string): Promise<void> {
|
|
336
|
+
const link = buildAppLink(dependencies.appBaseUrl, APP_LINK_PATHS.inviteAccept, token)
|
|
337
|
+
|
|
313
338
|
return dependencies.email.send({
|
|
314
339
|
to,
|
|
315
340
|
subject: 'You have been invited to a Kelpie workspace',
|
|
316
|
-
body: `Accept the invitation within seven days:\n\n${
|
|
341
|
+
body: `Accept the invitation within seven days:\n\n${link}`,
|
|
317
342
|
})
|
|
318
343
|
}
|
|
319
344
|
|
|
@@ -370,6 +395,8 @@ export function createWorkspaceService(dependencies: WorkspaceDependencies): Wor
|
|
|
370
395
|
|
|
371
396
|
return {
|
|
372
397
|
async create(actor, input) {
|
|
398
|
+
await requireVerifiedEmail(actor)
|
|
399
|
+
|
|
373
400
|
const now = dependencies.now()
|
|
374
401
|
const workspaceId = dependencies.createId('workspace')
|
|
375
402
|
const memberId = dependencies.createId('teamMember')
|
|
@@ -559,7 +586,7 @@ export function createWorkspaceService(dependencies: WorkspaceDependencies): Wor
|
|
|
559
586
|
// `schema.md`: removing a member is restricted while they own records.
|
|
560
587
|
// Reported before the delete so every referencing type can be named, which
|
|
561
588
|
// is what the caller needs to know what to reassign.
|
|
562
|
-
const references = await repository.countMemberReferences(dependencies.db, target.id)
|
|
589
|
+
const references = await repository.countMemberReferences(dependencies.db, workspaceId, target.id)
|
|
563
590
|
|
|
564
591
|
if (references.length > 0) {
|
|
565
592
|
throw AppError.conflict(
|
|
@@ -589,7 +616,7 @@ export function createWorkspaceService(dependencies: WorkspaceDependencies): Wor
|
|
|
589
616
|
}
|
|
590
617
|
},
|
|
591
618
|
|
|
592
|
-
async invite(actor, workspaceId, email, role
|
|
619
|
+
async invite(actor, workspaceId, email, role) {
|
|
593
620
|
const inviter = await requireMembership(actor, workspaceId, 'admin')
|
|
594
621
|
const address = email.trim().toLowerCase()
|
|
595
622
|
const now = dependencies.now()
|
|
@@ -637,7 +664,7 @@ export function createWorkspaceService(dependencies: WorkspaceDependencies): Wor
|
|
|
637
664
|
})
|
|
638
665
|
|
|
639
666
|
// Sent after commit, so a rolled-back invite never reaches an inbox.
|
|
640
|
-
await sendInviteEmail(invite.email, token
|
|
667
|
+
await sendInviteEmail(invite.email, token)
|
|
641
668
|
|
|
642
669
|
return toInviteView(invite, now)
|
|
643
670
|
},
|
|
@@ -650,7 +677,7 @@ export function createWorkspaceService(dependencies: WorkspaceDependencies): Wor
|
|
|
650
677
|
return records.map((record) => toInviteView(record, now))
|
|
651
678
|
},
|
|
652
679
|
|
|
653
|
-
async resendInvite(actor, workspaceId, inviteId
|
|
680
|
+
async resendInvite(actor, workspaceId, inviteId) {
|
|
654
681
|
await requireMembership(actor, workspaceId, 'admin')
|
|
655
682
|
const invite = await requireInvite(workspaceId, inviteId)
|
|
656
683
|
|
|
@@ -674,7 +701,7 @@ export function createWorkspaceService(dependencies: WorkspaceDependencies): Wor
|
|
|
674
701
|
throw AppError.notFound('Invitation not found')
|
|
675
702
|
}
|
|
676
703
|
|
|
677
|
-
await sendInviteEmail(updated.email, token
|
|
704
|
+
await sendInviteEmail(updated.email, token)
|
|
678
705
|
|
|
679
706
|
return toInviteView(updated, now)
|
|
680
707
|
},
|
|
@@ -702,10 +729,10 @@ export function createWorkspaceService(dependencies: WorkspaceDependencies): Wor
|
|
|
702
729
|
actor.userId,
|
|
703
730
|
)
|
|
704
731
|
|
|
705
|
-
// The invitation is left alone
|
|
706
|
-
//
|
|
707
|
-
//
|
|
708
|
-
//
|
|
732
|
+
// The invitation is left alone rather than deleted: the caller already
|
|
733
|
+
// belongs, so this token cannot be theirs, and deleting somebody else's
|
|
734
|
+
// invitation because a different account presented it would strand the
|
|
735
|
+
// person it was actually sent to.
|
|
709
736
|
if (existing !== undefined) {
|
|
710
737
|
throw AppError.conflict('You already belong to that workspace')
|
|
711
738
|
}
|
|
@@ -716,6 +743,14 @@ export function createWorkspaceService(dependencies: WorkspaceDependencies): Wor
|
|
|
716
743
|
throw new Error(`Session ${actor.sessionId} outlived the user row behind it`)
|
|
717
744
|
}
|
|
718
745
|
|
|
746
|
+
// An invitation names an address, not a person. Accepting it with a
|
|
747
|
+
// different account would let a leaked or forwarded token seat anyone,
|
|
748
|
+
// and would let that account claim credit for controlling an address
|
|
749
|
+
// it never proved control of.
|
|
750
|
+
if (joiner.email !== invite.email) {
|
|
751
|
+
throw AppError.unauthorized('That invitation is invalid or has expired')
|
|
752
|
+
}
|
|
753
|
+
|
|
719
754
|
// Ownership is never invited, only created or transferred. The column's
|
|
720
755
|
// check constraint says the same; this is the service-side half of it.
|
|
721
756
|
const role: MemberRole = invite.role === 'admin' ? 'admin' : 'member'
|
|
@@ -740,14 +775,12 @@ export function createWorkspaceService(dependencies: WorkspaceDependencies): Wor
|
|
|
740
775
|
throw error
|
|
741
776
|
}
|
|
742
777
|
|
|
743
|
-
|
|
778
|
+
// A real invite link already proves control of the invited address, so
|
|
779
|
+
// accepting one satisfies the same gate a verification link would.
|
|
780
|
+
// A no-op if the account was already verified.
|
|
781
|
+
await authRepository.markEmailVerified(tx, actor.userId, now)
|
|
744
782
|
|
|
745
|
-
|
|
746
|
-
// has just killed: accepting one is now the refusal above, and until it
|
|
747
|
-
// expires it holds a seat and lists them beside their own membership.
|
|
748
|
-
// Accepting a token addressed to somebody else is how one survives the
|
|
749
|
-
// line before it.
|
|
750
|
-
await repository.deleteInvitesForEmail(tx, invite.workspaceId, joiner.email)
|
|
783
|
+
await repository.deleteInvite(tx, invite.id)
|
|
751
784
|
|
|
752
785
|
await authRepository.setActiveWorkspace(tx, actor.sessionId, invite.workspaceId)
|
|
753
786
|
|
|
@@ -3,7 +3,7 @@ import { z } from 'zod'
|
|
|
3
3
|
import { requireWorkspaceId } from '../../lib/actor.ts'
|
|
4
4
|
import type { McpToolRegistry } from '../../runtime/module.ts'
|
|
5
5
|
import { deleteResult, idArg } from '../crudTools.ts'
|
|
6
|
-
import { inviteBody, inviteResponse, memberResponse, memberRoleBody,
|
|
6
|
+
import { inviteBody, inviteResponse, memberResponse, memberRoleBody, updateBody, workspaceResponse } from './routes.ts'
|
|
7
7
|
import type { WorkspaceService } from './service.ts'
|
|
8
8
|
|
|
9
9
|
/**
|
|
@@ -82,19 +82,12 @@ export function registerWorkspaceTools(mcp: McpToolRegistry, service: WorkspaceS
|
|
|
82
82
|
mcp.tool({
|
|
83
83
|
name: 'workspace_invites_create',
|
|
84
84
|
description:
|
|
85
|
-
'Invite somebody by email and send them the invitation.
|
|
86
|
-
'
|
|
87
|
-
'Mirrors POST /v1/workspaces/{id}/invites.',
|
|
85
|
+
'Invite somebody by email and send them the invitation. The link is built from the ' +
|
|
86
|
+
'deployment\'s own base URL. Admin only. Mirrors POST /v1/workspaces/{id}/invites.',
|
|
88
87
|
inputSchema: inviteBody,
|
|
89
88
|
invoke: async (body, actor) =>
|
|
90
89
|
inviteResponse(
|
|
91
|
-
await service.invite(
|
|
92
|
-
actor,
|
|
93
|
-
requireWorkspaceId(actor),
|
|
94
|
-
body.email,
|
|
95
|
-
body.role,
|
|
96
|
-
body.invite_url_template,
|
|
97
|
-
),
|
|
90
|
+
await service.invite(actor, requireWorkspaceId(actor), body.email, body.role),
|
|
98
91
|
),
|
|
99
92
|
})
|
|
100
93
|
|
|
@@ -114,16 +107,9 @@ export function registerWorkspaceTools(mcp: McpToolRegistry, service: WorkspaceS
|
|
|
114
107
|
description:
|
|
115
108
|
'Issue a fresh token and expiry for an invitation and email it again. Admin only. ' +
|
|
116
109
|
'Mirrors POST /v1/workspaces/{id}/invites/{inviteId}/resend.',
|
|
117
|
-
inputSchema:
|
|
118
|
-
invoke: async (
|
|
119
|
-
inviteResponse(
|
|
120
|
-
await service.resendInvite(
|
|
121
|
-
actor,
|
|
122
|
-
requireWorkspaceId(actor),
|
|
123
|
-
args.invite_id,
|
|
124
|
-
args.invite_url_template,
|
|
125
|
-
),
|
|
126
|
-
),
|
|
110
|
+
inputSchema: z.strictObject({ invite_id: idArg }),
|
|
111
|
+
invoke: async ({ invite_id: inviteId }, actor) =>
|
|
112
|
+
inviteResponse(await service.resendInvite(actor, requireWorkspaceId(actor), inviteId)),
|
|
127
113
|
})
|
|
128
114
|
|
|
129
115
|
mcp.tool({
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Context, MiddlewareHandler } from 'hono'
|
|
2
|
+
|
|
3
|
+
import { AppError } from '../../lib/errors.ts'
|
|
4
|
+
import type { EntitlementRegistry } from '../../runtime/entitlements.ts'
|
|
5
|
+
import { actorWorkspaceId } from '../auth/actor.ts'
|
|
6
|
+
import { resolveActorFrom } from '../auth/credentials.ts'
|
|
7
|
+
import type { CredentialDependencies } from '../auth/credentials.ts'
|
|
8
|
+
import { WORKSPACE_ACCESS } from './capabilities.ts'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `POST /v1/*` and every `/mcp` call carry a fixed workspace for the whole
|
|
12
|
+
* request (`api.md`): a session or key resolves to one `Actor.workspaceId`,
|
|
13
|
+
* and every query it makes is scoped to that workspace server-side. This
|
|
14
|
+
* blocks the request outright when that workspace's `workspace.access`
|
|
15
|
+
* entitlement is not granted — inert in a self-hosted assembly, since
|
|
16
|
+
* nothing registers a `GrantProvider` that answers it there
|
|
17
|
+
* (`capabilities.ts`).
|
|
18
|
+
*
|
|
19
|
+
* Mounted the same way as `createIdempotencyMiddleware`: tolerates the
|
|
20
|
+
* no-credential case, since an unauthenticated `/v1/auth/*` call has
|
|
21
|
+
* nothing to check, and ahead of idempotency in `app.ts` so a blocked
|
|
22
|
+
* request never reserves a key it will not be allowed to spend.
|
|
23
|
+
*
|
|
24
|
+
* Exempts `/v1/auth/*` and `/v1/account*`: they manage the *account*, not
|
|
25
|
+
* the workspace, and a member of a workspace this gate has locked out
|
|
26
|
+
* still needs to sign in, sign out, and see their own account to have any
|
|
27
|
+
* chance of understanding why. There is no `/v1` endpoint to switch a
|
|
28
|
+
* session to a different workspace membership — a session is bound to one
|
|
29
|
+
* at login — so there is nothing further to exempt for that case.
|
|
30
|
+
*/
|
|
31
|
+
function isExempt(path: string): boolean {
|
|
32
|
+
return path.startsWith('/v1/auth/') || path.startsWith('/v1/account')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface WorkspaceAccessMiddlewareDependencies extends CredentialDependencies {
|
|
36
|
+
readonly entitlements: EntitlementRegistry
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createWorkspaceAccessMiddleware(
|
|
40
|
+
dependencies: WorkspaceAccessMiddlewareDependencies,
|
|
41
|
+
): MiddlewareHandler {
|
|
42
|
+
return async (context: Context, next) => {
|
|
43
|
+
if (isExempt(context.req.path)) {
|
|
44
|
+
await next()
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const actor = await resolveActorFrom(dependencies, context).catch((error: unknown) => {
|
|
49
|
+
if (error instanceof AppError && error.code === 'unauthorized') {
|
|
50
|
+
// No credential to check a workspace against. The handler this
|
|
51
|
+
// reaches answers its own 401; there is nothing this gate can add.
|
|
52
|
+
return undefined
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
throw error
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const workspaceId = actor === undefined ? null : actorWorkspaceId(actor)
|
|
59
|
+
|
|
60
|
+
// Between signup and a first workspace there is nothing to gate
|
|
61
|
+
// (`POST /v1/workspaces`, `POST /v1/invites/accept`): the same case
|
|
62
|
+
// `createIdempotencyMiddleware` lets through unscoped.
|
|
63
|
+
if (workspaceId === null) {
|
|
64
|
+
await next()
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const entitlement = await dependencies.entitlements.check(workspaceId, WORKSPACE_ACCESS.name)
|
|
69
|
+
|
|
70
|
+
if (entitlement.kind === 'flag' && !entitlement.granted) {
|
|
71
|
+
throw new AppError('entitlement_required', 'This workspace does not have access')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
await next()
|
|
75
|
+
}
|
|
76
|
+
}
|
package/src/runtime/module.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Hono } from 'hono'
|
|
1
|
+
import type { Handler, Hono, MiddlewareHandler } from 'hono'
|
|
2
2
|
import type { ZodType } from 'zod'
|
|
3
3
|
|
|
4
4
|
import type { Actor } from '../lib/actor.ts'
|
|
@@ -93,6 +93,30 @@ export interface ModuleContext extends ModuleServices {
|
|
|
93
93
|
* scope every query to that. There is no other way in.
|
|
94
94
|
*/
|
|
95
95
|
publicRoutes(mount: (router: Hono) => void): void
|
|
96
|
+
/**
|
|
97
|
+
* Declares one route at its real, full path on the app itself, outside
|
|
98
|
+
* `/v1`. Core applies the declaration; no router exists between the module
|
|
99
|
+
* and the app.
|
|
100
|
+
*
|
|
101
|
+
* Nothing is put in front of it: no actor resolution, no workspace, no
|
|
102
|
+
* `module.<id>` capability gate, and workspace settings cannot toggle it.
|
|
103
|
+
* A surface declared this way owns its access rules, normally by pairing
|
|
104
|
+
* these with `appMiddleware`. Paths at or under `/v1`, `/mcp`, or
|
|
105
|
+
* `/healthz` are refused at boot: those are core's surfaces, and `routes`
|
|
106
|
+
* is the only way under `/v1`.
|
|
107
|
+
*/
|
|
108
|
+
appRoute(method: 'GET' | 'POST' | 'PATCH' | 'DELETE', path: string, handler: Handler): void
|
|
109
|
+
/**
|
|
110
|
+
* Declares middleware for a path pattern (`/operator/api/*`), the other
|
|
111
|
+
* half of `appRoute`.
|
|
112
|
+
*
|
|
113
|
+
* The app applies every declared middleware before any declared route, in
|
|
114
|
+
* module registration order, so a pattern covers matching routes from
|
|
115
|
+
* every module, later-registered ones included. That is what lets one
|
|
116
|
+
* module guard a surface other modules add routes to. Reserved paths are
|
|
117
|
+
* refused as for `appRoute`.
|
|
118
|
+
*/
|
|
119
|
+
appMiddleware(pattern: string, handler: MiddlewareHandler): void
|
|
96
120
|
schema(tables: Readonly<Record<string, unknown>>, migrationsDir: string): void
|
|
97
121
|
readonly mcp: McpToolRegistry
|
|
98
122
|
/**
|
package/src/runtime/registry.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Hono } from 'hono'
|
|
2
|
-
import type { Context } from 'hono'
|
|
2
|
+
import type { Context, Handler, MiddlewareHandler } from 'hono'
|
|
3
3
|
|
|
4
4
|
import type { Actor } from '../lib/actor.ts'
|
|
5
5
|
import { requireWorkspaceId } from '../lib/actor.ts'
|
|
@@ -30,6 +30,10 @@ export interface ModuleContributions {
|
|
|
30
30
|
readonly routers: readonly ModuleRouter[]
|
|
31
31
|
/** Routers for `/v1/public`: no credentials, CORS open. `architecture.md` boot step 5. */
|
|
32
32
|
readonly publicRouters: readonly ModuleRouter[]
|
|
33
|
+
/** Middleware declared on the app itself. Applied before every `appRoutes` entry. */
|
|
34
|
+
readonly appMiddleware: readonly AppMiddlewareContribution[]
|
|
35
|
+
/** Routes declared at full paths on the app itself, outside `/v1`. */
|
|
36
|
+
readonly appRoutes: readonly AppRouteContribution[]
|
|
33
37
|
readonly schemas: readonly SchemaContribution[]
|
|
34
38
|
readonly mcpTools: readonly McpTool[]
|
|
35
39
|
/** The bus every module subscribed to. Services publish through it after commit. */
|
|
@@ -43,6 +47,41 @@ export interface ModuleRouter {
|
|
|
43
47
|
readonly router: Hono
|
|
44
48
|
}
|
|
45
49
|
|
|
50
|
+
export interface AppRouteContribution {
|
|
51
|
+
readonly moduleId: string
|
|
52
|
+
readonly method: 'GET' | 'POST' | 'PATCH' | 'DELETE'
|
|
53
|
+
readonly path: string
|
|
54
|
+
readonly handler: Handler
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface AppMiddlewareContribution {
|
|
58
|
+
readonly moduleId: string
|
|
59
|
+
readonly pattern: string
|
|
60
|
+
readonly handler: MiddlewareHandler
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Paths `appRoute`/`appMiddleware` may not touch. `/v1` because `routes` with
|
|
65
|
+
* its gates is the only way there, the rest because they are core's own
|
|
66
|
+
* surfaces. `/mcp` mirrors `MCP_ROUTE_PREFIX`, written out because `runtime/`
|
|
67
|
+
* must not import a feature module.
|
|
68
|
+
*/
|
|
69
|
+
const RESERVED_APP_PATHS: readonly string[] = ['/v1', '/mcp', '/healthz']
|
|
70
|
+
|
|
71
|
+
function assertMountablePath(moduleId: string, kind: string, path: string): void {
|
|
72
|
+
if (!path.startsWith('/')) {
|
|
73
|
+
throw new ModuleBootError([`module "${moduleId}" declares ${kind} "${path}", which must start with "/"`])
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for (const reserved of RESERVED_APP_PATHS) {
|
|
77
|
+
if (path === reserved || path.startsWith(`${reserved}/`)) {
|
|
78
|
+
throw new ModuleBootError([
|
|
79
|
+
`module "${moduleId}" declares ${kind} "${path}" under "${reserved}", which is core's surface`,
|
|
80
|
+
])
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
46
85
|
/**
|
|
47
86
|
* What `registerModules` needs.
|
|
48
87
|
*
|
|
@@ -87,6 +126,8 @@ export interface ModuleRuntimeOptions {
|
|
|
87
126
|
interface Accumulator {
|
|
88
127
|
readonly routers: ModuleRouter[]
|
|
89
128
|
readonly publicRouters: ModuleRouter[]
|
|
129
|
+
readonly appMiddleware: AppMiddlewareContribution[]
|
|
130
|
+
readonly appRoutes: AppRouteContribution[]
|
|
90
131
|
readonly schemas: SchemaContribution[]
|
|
91
132
|
readonly mcpTools: McpTool[]
|
|
92
133
|
}
|
|
@@ -130,6 +171,19 @@ function createModuleContext(
|
|
|
130
171
|
accumulator.publicRouters.push({ moduleId: module.id, router })
|
|
131
172
|
},
|
|
132
173
|
|
|
174
|
+
// No gate on either, unlike `routes`: a surface declared here owns its
|
|
175
|
+
// own access rules, and a per-module `module.<id>` check would hand
|
|
176
|
+
// workspaces a switch over deployment tooling they do not own.
|
|
177
|
+
appRoute(method, path, handler) {
|
|
178
|
+
assertMountablePath(module.id, 'app route', path)
|
|
179
|
+
accumulator.appRoutes.push({ moduleId: module.id, method, path, handler })
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
appMiddleware(pattern, handler) {
|
|
183
|
+
assertMountablePath(module.id, 'app middleware', pattern)
|
|
184
|
+
accumulator.appMiddleware.push({ moduleId: module.id, pattern, handler })
|
|
185
|
+
},
|
|
186
|
+
|
|
133
187
|
schema(tables, migrationsDir) {
|
|
134
188
|
accumulator.schemas.push({ moduleId: module.id, tables, migrationsDir })
|
|
135
189
|
},
|
|
@@ -211,6 +265,8 @@ export async function registerModules(options: ModuleRuntimeOptions): Promise<Mo
|
|
|
211
265
|
const accumulator: Accumulator = {
|
|
212
266
|
routers: [],
|
|
213
267
|
publicRouters: [],
|
|
268
|
+
appMiddleware: [],
|
|
269
|
+
appRoutes: [],
|
|
214
270
|
schemas: [],
|
|
215
271
|
mcpTools: [],
|
|
216
272
|
}
|
|
@@ -265,6 +321,8 @@ export async function registerModules(options: ModuleRuntimeOptions): Promise<Mo
|
|
|
265
321
|
return {
|
|
266
322
|
routers: accumulator.routers,
|
|
267
323
|
publicRouters: accumulator.publicRouters,
|
|
324
|
+
appMiddleware: accumulator.appMiddleware,
|
|
325
|
+
appRoutes: accumulator.appRoutes,
|
|
268
326
|
schemas: accumulator.schemas,
|
|
269
327
|
mcpTools: accumulator.mcpTools,
|
|
270
328
|
events,
|
package/src/testing/client.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { eq } from 'drizzle-orm'
|
|
1
2
|
import type { Hono } from 'hono'
|
|
2
3
|
|
|
3
4
|
import type { AppBindings } from '../app.ts'
|
|
5
|
+
import type { Database } from '../lib/database.ts'
|
|
6
|
+
import { users } from '../modules/auth/schema.ts'
|
|
4
7
|
|
|
5
8
|
/**
|
|
6
9
|
* An HTTP client for integration tests, plus the two setups almost every suite
|
|
@@ -68,7 +71,8 @@ export function readCursor(payload: unknown): string | null {
|
|
|
68
71
|
return payload.next_cursor
|
|
69
72
|
}
|
|
70
73
|
|
|
71
|
-
|
|
74
|
+
/** @param db Verifies a fresh signup directly, so almost every suite can start from a working owner. */
|
|
75
|
+
export function createTestClient(app: Hono<AppBindings>, db: Database): TestClient {
|
|
72
76
|
function send(method: string, path: string, options: TestRequestOptions = {}): Promise<Response> {
|
|
73
77
|
return Promise.resolve(
|
|
74
78
|
app.request(path, {
|
|
@@ -83,15 +87,31 @@ export function createTestClient(app: Hono<AppBindings>): TestClient {
|
|
|
83
87
|
)
|
|
84
88
|
}
|
|
85
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Signs up and verifies directly against the database.
|
|
92
|
+
*
|
|
93
|
+
* These suites are about the CRM, not verification: going through the real
|
|
94
|
+
* emailed link for every signup would mean threading the email spy through
|
|
95
|
+
* this client too, for a step almost nothing here is testing. `auth.test.ts`
|
|
96
|
+
* and `workspace.test.ts` cover the real link.
|
|
97
|
+
*/
|
|
86
98
|
async function signUp(email: string): Promise<string> {
|
|
87
99
|
const response = await send('POST', '/v1/auth/signup', {
|
|
88
|
-
body: {
|
|
100
|
+
body: {
|
|
101
|
+
email,
|
|
102
|
+
name: 'Someone',
|
|
103
|
+
password: 'correct horse battery staple',
|
|
104
|
+
},
|
|
89
105
|
})
|
|
90
106
|
|
|
91
107
|
if (response.status !== 201) {
|
|
92
108
|
throw new Error(`Signing up ${email} answered ${String(response.status)}`)
|
|
93
109
|
}
|
|
94
110
|
|
|
111
|
+
const accountId = readString(readRecord(await response.json()).account, 'id')
|
|
112
|
+
|
|
113
|
+
await db.update(users).set({ emailVerifiedAt: new Date() }).where(eq(users.id, accountId))
|
|
114
|
+
|
|
95
115
|
// Only the cookie's name=value matters to a later request; the attributes
|
|
96
116
|
// after the first semicolon are for a browser.
|
|
97
117
|
return (response.headers.get('Set-Cookie') ?? '').split(';')[0] ?? ''
|
|
@@ -16,7 +16,11 @@ import type { Environment } from '../lib/config.ts'
|
|
|
16
16
|
*/
|
|
17
17
|
export const TEST_SECRET_ENCRYPTION_KEY = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='
|
|
18
18
|
|
|
19
|
+
/** The base URL every emailed link is built from in tests. */
|
|
20
|
+
export const TEST_APP_BASE_URL = 'https://kelpie.test'
|
|
21
|
+
|
|
19
22
|
export const TEST_ENVIRONMENT: Environment = {
|
|
20
23
|
NODE_ENV: 'test',
|
|
21
24
|
SECRET_ENCRYPTION_KEY: TEST_SECRET_ENCRYPTION_KEY,
|
|
25
|
+
APP_BASE_URL: TEST_APP_BASE_URL,
|
|
22
26
|
}
|