@kelpie/server 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/dist/app.d.ts.map +1 -1
  2. package/dist/app.js +14 -0
  3. package/dist/app.js.map +1 -1
  4. package/dist/index.d.ts +2 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +1 -0
  7. package/dist/index.js.map +1 -1
  8. package/dist/lib/appUrl.d.ts +27 -0
  9. package/dist/lib/appUrl.d.ts.map +1 -0
  10. package/dist/lib/appUrl.js +53 -0
  11. package/dist/lib/appUrl.js.map +1 -0
  12. package/dist/lib/clientIp.d.ts +24 -0
  13. package/dist/lib/clientIp.d.ts.map +1 -0
  14. package/dist/lib/clientIp.js +34 -0
  15. package/dist/lib/clientIp.js.map +1 -0
  16. package/dist/lib/config.d.ts +6 -0
  17. package/dist/lib/config.d.ts.map +1 -1
  18. package/dist/lib/config.js +2 -0
  19. package/dist/lib/config.js.map +1 -1
  20. package/dist/lib/egress.d.ts +44 -0
  21. package/dist/lib/egress.d.ts.map +1 -0
  22. package/dist/lib/egress.js +111 -0
  23. package/dist/lib/egress.js.map +1 -0
  24. package/dist/lib/ids.d.ts +2 -0
  25. package/dist/lib/ids.d.ts.map +1 -1
  26. package/dist/lib/ids.js +2 -0
  27. package/dist/lib/ids.js.map +1 -1
  28. package/dist/lib/rateLimit.d.ts +12 -4
  29. package/dist/lib/rateLimit.d.ts.map +1 -1
  30. package/dist/lib/rateLimit.js +6 -0
  31. package/dist/lib/rateLimit.js.map +1 -1
  32. package/dist/modules/agent-tasks/dispatch.d.ts +4 -2
  33. package/dist/modules/agent-tasks/dispatch.d.ts.map +1 -1
  34. package/dist/modules/agent-tasks/dispatch.js +4 -2
  35. package/dist/modules/agent-tasks/dispatch.js.map +1 -1
  36. package/dist/modules/agent-tasks/index.d.ts.map +1 -1
  37. package/dist/modules/agent-tasks/index.js +3 -1
  38. package/dist/modules/agent-tasks/index.js.map +1 -1
  39. package/dist/modules/auth/index.d.ts.map +1 -1
  40. package/dist/modules/auth/index.js +10 -3
  41. package/dist/modules/auth/index.js.map +1 -1
  42. package/dist/modules/auth/repository.d.ts +13 -1
  43. package/dist/modules/auth/repository.d.ts.map +1 -1
  44. package/dist/modules/auth/repository.js +32 -1
  45. package/dist/modules/auth/repository.js.map +1 -1
  46. package/dist/modules/auth/routes.d.ts.map +1 -1
  47. package/dist/modules/auth/routes.js +29 -6
  48. package/dist/modules/auth/routes.js.map +1 -1
  49. package/dist/modules/auth/schema.d.ts +130 -0
  50. package/dist/modules/auth/schema.d.ts.map +1 -1
  51. package/dist/modules/auth/schema.js +16 -0
  52. package/dist/modules/auth/schema.js.map +1 -1
  53. package/dist/modules/auth/service.d.ts +7 -1
  54. package/dist/modules/auth/service.d.ts.map +1 -1
  55. package/dist/modules/auth/service.js +53 -4
  56. package/dist/modules/auth/service.js.map +1 -1
  57. package/dist/modules/deals/repository.d.ts.map +1 -1
  58. package/dist/modules/deals/repository.js +1 -0
  59. package/dist/modules/deals/repository.js.map +1 -1
  60. package/dist/modules/decisions/repository.d.ts.map +1 -1
  61. package/dist/modules/decisions/repository.js +8 -7
  62. package/dist/modules/decisions/repository.js.map +1 -1
  63. package/dist/modules/forms/index.d.ts.map +1 -1
  64. package/dist/modules/forms/index.js +6 -1
  65. package/dist/modules/forms/index.js.map +1 -1
  66. package/dist/modules/forms/publicRoutes.d.ts +3 -0
  67. package/dist/modules/forms/publicRoutes.d.ts.map +1 -1
  68. package/dist/modules/forms/publicRoutes.js +10 -4
  69. package/dist/modules/forms/publicRoutes.js.map +1 -1
  70. package/dist/modules/forms/submission.d.ts +3 -0
  71. package/dist/modules/forms/submission.d.ts.map +1 -1
  72. package/dist/modules/forms/submission.js +6 -0
  73. package/dist/modules/forms/submission.js.map +1 -1
  74. package/dist/modules/opportunities/repository.d.ts.map +1 -1
  75. package/dist/modules/opportunities/repository.js +1 -0
  76. package/dist/modules/opportunities/repository.js.map +1 -1
  77. package/dist/modules/partnerships/repository.d.ts.map +1 -1
  78. package/dist/modules/partnerships/repository.js +1 -0
  79. package/dist/modules/partnerships/repository.js.map +1 -1
  80. package/dist/modules/raises/repository.d.ts.map +1 -1
  81. package/dist/modules/raises/repository.js +1 -0
  82. package/dist/modules/raises/repository.js.map +1 -1
  83. package/dist/modules/rate-limit/middleware.d.ts +4 -4
  84. package/dist/modules/rate-limit/middleware.d.ts.map +1 -1
  85. package/dist/modules/rate-limit/middleware.js +57 -11
  86. package/dist/modules/rate-limit/middleware.js.map +1 -1
  87. package/dist/modules/webhooks/delivery.d.ts +5 -2
  88. package/dist/modules/webhooks/delivery.d.ts.map +1 -1
  89. package/dist/modules/webhooks/delivery.js +5 -2
  90. package/dist/modules/webhooks/delivery.js.map +1 -1
  91. package/dist/modules/webhooks/index.d.ts.map +1 -1
  92. package/dist/modules/webhooks/index.js +3 -1
  93. package/dist/modules/webhooks/index.js.map +1 -1
  94. package/dist/modules/workspace/idempotencyMiddleware.d.ts.map +1 -1
  95. package/dist/modules/workspace/idempotencyMiddleware.js +14 -1
  96. package/dist/modules/workspace/idempotencyMiddleware.js.map +1 -1
  97. package/dist/modules/workspace/index.d.ts.map +1 -1
  98. package/dist/modules/workspace/index.js +3 -0
  99. package/dist/modules/workspace/index.js.map +1 -1
  100. package/dist/modules/workspace/repository.d.ts +1 -1
  101. package/dist/modules/workspace/repository.d.ts.map +1 -1
  102. package/dist/modules/workspace/repository.js +15 -9
  103. package/dist/modules/workspace/repository.js.map +1 -1
  104. package/dist/modules/workspace/routes.d.ts +0 -4
  105. package/dist/modules/workspace/routes.d.ts.map +1 -1
  106. package/dist/modules/workspace/routes.js +2 -7
  107. package/dist/modules/workspace/routes.js.map +1 -1
  108. package/dist/modules/workspace/service.d.ts +4 -2
  109. package/dist/modules/workspace/service.d.ts.map +1 -1
  110. package/dist/modules/workspace/service.js +42 -17
  111. package/dist/modules/workspace/service.js.map +1 -1
  112. package/dist/modules/workspace/tools.d.ts.map +1 -1
  113. package/dist/modules/workspace/tools.js +6 -7
  114. package/dist/modules/workspace/tools.js.map +1 -1
  115. package/dist/operator.d.ts +40 -0
  116. package/dist/operator.d.ts.map +1 -0
  117. package/dist/operator.js +50 -0
  118. package/dist/operator.js.map +1 -0
  119. package/dist/runtime/module.d.ts +25 -1
  120. package/dist/runtime/module.d.ts.map +1 -1
  121. package/dist/runtime/registry.d.ts +16 -1
  122. package/dist/runtime/registry.d.ts.map +1 -1
  123. package/dist/runtime/registry.js +34 -0
  124. package/dist/runtime/registry.js.map +1 -1
  125. package/dist/testing/client.d.ts +3 -1
  126. package/dist/testing/client.d.ts.map +1 -1
  127. package/dist/testing/client.js +19 -2
  128. package/dist/testing/client.js.map +1 -1
  129. package/dist/testing/environment.d.ts +2 -0
  130. package/dist/testing/environment.d.ts.map +1 -1
  131. package/dist/testing/environment.js +3 -0
  132. package/dist/testing/environment.js.map +1 -1
  133. package/dist/webBundle.d.ts +8 -0
  134. package/dist/webBundle.d.ts.map +1 -1
  135. package/dist/webBundle.js +9 -7
  136. package/dist/webBundle.js.map +1 -1
  137. package/migrations/0018_hard_mystique.sql +12 -0
  138. package/migrations/meta/0018_snapshot.json +5113 -0
  139. package/migrations/meta/_journal.json +8 -1
  140. package/package.json +2 -2
  141. package/src/app.ts +16 -0
  142. package/src/index.ts +9 -1
  143. package/src/lib/appUrl.ts +64 -0
  144. package/src/lib/clientIp.ts +41 -0
  145. package/src/lib/config.ts +8 -0
  146. package/src/lib/egress.ts +149 -0
  147. package/src/lib/ids.ts +2 -0
  148. package/src/lib/rateLimit.ts +16 -4
  149. package/src/modules/agent-tasks/dispatch.ts +9 -2
  150. package/src/modules/agent-tasks/index.ts +3 -1
  151. package/src/modules/auth/index.ts +12 -5
  152. package/src/modules/auth/repository.ts +56 -1
  153. package/src/modules/auth/routes.ts +34 -6
  154. package/src/modules/auth/schema.ts +17 -0
  155. package/src/modules/auth/service.ts +78 -5
  156. package/src/modules/deals/repository.ts +1 -0
  157. package/src/modules/decisions/repository.ts +8 -7
  158. package/src/modules/forms/index.ts +6 -1
  159. package/src/modules/forms/publicRoutes.ts +14 -4
  160. package/src/modules/forms/submission.ts +10 -0
  161. package/src/modules/opportunities/repository.ts +1 -0
  162. package/src/modules/partnerships/repository.ts +1 -0
  163. package/src/modules/raises/repository.ts +1 -0
  164. package/src/modules/rate-limit/middleware.ts +74 -11
  165. package/src/modules/webhooks/delivery.ts +10 -2
  166. package/src/modules/webhooks/index.ts +3 -1
  167. package/src/modules/workspace/idempotencyMiddleware.ts +17 -1
  168. package/src/modules/workspace/index.ts +4 -0
  169. package/src/modules/workspace/repository.ts +15 -8
  170. package/src/modules/workspace/routes.ts +0 -8
  171. package/src/modules/workspace/service.ts +53 -20
  172. package/src/modules/workspace/tools.ts +7 -21
  173. package/src/runtime/module.ts +25 -1
  174. package/src/runtime/registry.ts +59 -1
  175. package/src/testing/client.ts +22 -2
  176. package/src/testing/environment.ts +4 -0
  177. package/src/webBundle.ts +21 -7
@@ -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, urlTemplate: string): Promise<InviteView>
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, urlTemplate: string): Promise<InviteView>
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, urlTemplate: string): Promise<void> {
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${urlTemplate.replace('{token}', token)}`,
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, urlTemplate) {
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, urlTemplate)
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, urlTemplate) {
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, urlTemplate)
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. A link is forwarded often enough that the
706
- // address on this one may not be the caller's, and revoking somebody
707
- // else's invitation because you clicked their link would be worse than
708
- // the refusal.
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
- await repository.deleteInvite(tx, invite.id)
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
- // Any other invitation to the joiner's own address, which their joining
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, resendBody, updateBody, workspaceResponse } from './routes.ts'
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. invite_url_template is the page ' +
86
- 'that accepts it and must contain {token}. Admin only. ' +
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: resendBody.extend({ invite_id: idArg }),
118
- invoke: async (args, actor) =>
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({
@@ -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
  /**
@@ -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,
@@ -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
- export function createTestClient(app: Hono<AppBindings>): TestClient {
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: { email, name: 'Someone', password: 'correct horse battery staple' },
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 API_PREFIXES: readonly string[] = ['/v1', MCP_ROUTE_PREFIX, '/healthz']
53
+ const CORE_API_PREFIXES: readonly string[] = ['/v1', MCP_ROUTE_PREFIX, '/healthz']
45
54
 
46
- function isApiRequest(path: string): boolean {
47
- return API_PREFIXES.some((prefix) => path === prefix || path.startsWith(`${prefix}/`))
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(handler: MiddlewareHandler): MiddlewareHandler {
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
  }