@kelpie/server 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +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/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 +2 -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 +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/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,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({
|
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
|
}
|
package/src/webBundle.ts
CHANGED
|
@@ -25,6 +25,14 @@ import { MCP_ROUTE_PREFIX } from './modules/mcp/index.ts'
|
|
|
25
25
|
export interface WebBundleOptions {
|
|
26
26
|
/** Directory holding the built `index.html` and its assets. */
|
|
27
27
|
readonly directory: string
|
|
28
|
+
/**
|
|
29
|
+
* Prefixes the assembly's own modules answer on beyond core's, via
|
|
30
|
+
* `appRoute`/`appMiddleware`. An unmatched GET under one stays a JSON
|
|
31
|
+
* error instead of becoming the app shell, the same rule `/v1` gets. The
|
|
32
|
+
* assembly names them here because only it knows which of its declared
|
|
33
|
+
* paths are API rather than pages.
|
|
34
|
+
*/
|
|
35
|
+
readonly apiPrefixes?: readonly string[]
|
|
28
36
|
}
|
|
29
37
|
|
|
30
38
|
/** The bundle directory does not hold a build. Thrown at boot, never per request. */
|
|
@@ -36,15 +44,16 @@ export class WebBundleError extends Error {
|
|
|
36
44
|
}
|
|
37
45
|
|
|
38
46
|
/**
|
|
39
|
-
* The prefixes `createApp` answers on.
|
|
47
|
+
* The prefixes `createApp` itself answers on.
|
|
40
48
|
*
|
|
41
49
|
* `/v1/public` needs no entry of its own: it sits under `/v1`. `MCP_ROUTE_PREFIX`
|
|
42
50
|
* is imported rather than written out, so moving the endpoint moves this with it.
|
|
51
|
+
* An assembly's own API prefixes arrive through `WebBundleOptions.apiPrefixes`.
|
|
43
52
|
*/
|
|
44
|
-
const
|
|
53
|
+
const CORE_API_PREFIXES: readonly string[] = ['/v1', MCP_ROUTE_PREFIX, '/healthz']
|
|
45
54
|
|
|
46
|
-
function
|
|
47
|
-
return
|
|
55
|
+
function isApiRequestFor(prefixes: readonly string[]): (path: string) => boolean {
|
|
56
|
+
return (path) => prefixes.some((prefix) => path === prefix || path.startsWith(`${prefix}/`))
|
|
48
57
|
}
|
|
49
58
|
|
|
50
59
|
/**
|
|
@@ -60,7 +69,10 @@ function isApiRequest(path: string): boolean {
|
|
|
60
69
|
* not a page request, and only a `GET` or a `HEAD` can sensibly be answered with
|
|
61
70
|
* a document.
|
|
62
71
|
*/
|
|
63
|
-
function webRequestsOnly(
|
|
72
|
+
function webRequestsOnly(
|
|
73
|
+
isApiRequest: (path: string) => boolean,
|
|
74
|
+
handler: MiddlewareHandler,
|
|
75
|
+
): MiddlewareHandler {
|
|
64
76
|
return async (context, next) => {
|
|
65
77
|
const isDocumentRequest = context.req.method === 'GET' || context.req.method === 'HEAD'
|
|
66
78
|
|
|
@@ -93,10 +105,12 @@ export function serveWebBundle(app: Hono<AppBindings>, options: WebBundleOptions
|
|
|
93
105
|
)
|
|
94
106
|
}
|
|
95
107
|
|
|
108
|
+
const isApiRequest = isApiRequestFor([...CORE_API_PREFIXES, ...(options.apiPrefixes ?? [])])
|
|
109
|
+
|
|
96
110
|
// Two registrations rather than one: `serveStatic` calls `next()` when it
|
|
97
111
|
// finds no file, which is exactly the signal the fallback needs. A request
|
|
98
112
|
// for a real asset is answered by the first and never reaches the second.
|
|
99
|
-
app.use('*', webRequestsOnly(serveStatic({ root: directory })))
|
|
113
|
+
app.use('*', webRequestsOnly(isApiRequest, serveStatic({ root: directory })))
|
|
100
114
|
|
|
101
115
|
// The single-page fallback. The app decides what to draw from the address, so
|
|
102
116
|
// a deep link to `/people/per_01J…` has to return the same `index.html` even
|
|
@@ -106,5 +120,5 @@ export function serveWebBundle(app: Hono<AppBindings>, options: WebBundleOptions
|
|
|
106
120
|
// does by default. Narrowing this by `Accept` would turn a stale asset
|
|
107
121
|
// reference into a clean 404, and would also turn `curl /people/per_01J…`
|
|
108
122
|
// into one, so it is left alone.
|
|
109
|
-
app.use('*', webRequestsOnly(serveStatic({ path: indexHtml })))
|
|
123
|
+
app.use('*', webRequestsOnly(isApiRequest, serveStatic({ path: indexHtml })))
|
|
110
124
|
}
|