@kelpie/server 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/dist/app.d.ts.map +1 -1
  2. package/dist/app.js +14 -0
  3. package/dist/app.js.map +1 -1
  4. package/dist/index.d.ts +2 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +1 -0
  7. package/dist/index.js.map +1 -1
  8. package/dist/lib/appUrl.d.ts +27 -0
  9. package/dist/lib/appUrl.d.ts.map +1 -0
  10. package/dist/lib/appUrl.js +53 -0
  11. package/dist/lib/appUrl.js.map +1 -0
  12. package/dist/lib/clientIp.d.ts +24 -0
  13. package/dist/lib/clientIp.d.ts.map +1 -0
  14. package/dist/lib/clientIp.js +34 -0
  15. package/dist/lib/clientIp.js.map +1 -0
  16. package/dist/lib/config.d.ts +11 -0
  17. package/dist/lib/config.d.ts.map +1 -1
  18. package/dist/lib/config.js +23 -12
  19. package/dist/lib/config.js.map +1 -1
  20. package/dist/lib/egress.d.ts +44 -0
  21. package/dist/lib/egress.d.ts.map +1 -0
  22. package/dist/lib/egress.js +111 -0
  23. package/dist/lib/egress.js.map +1 -0
  24. package/dist/lib/email.d.ts +53 -9
  25. package/dist/lib/email.d.ts.map +1 -1
  26. package/dist/lib/email.js +49 -4
  27. package/dist/lib/email.js.map +1 -1
  28. package/dist/lib/ids.d.ts +2 -0
  29. package/dist/lib/ids.d.ts.map +1 -1
  30. package/dist/lib/ids.js +2 -0
  31. package/dist/lib/ids.js.map +1 -1
  32. package/dist/lib/rateLimit.d.ts +12 -4
  33. package/dist/lib/rateLimit.d.ts.map +1 -1
  34. package/dist/lib/rateLimit.js +6 -0
  35. package/dist/lib/rateLimit.js.map +1 -1
  36. package/dist/modules/agent-tasks/dispatch.d.ts +4 -2
  37. package/dist/modules/agent-tasks/dispatch.d.ts.map +1 -1
  38. package/dist/modules/agent-tasks/dispatch.js +4 -2
  39. package/dist/modules/agent-tasks/dispatch.js.map +1 -1
  40. package/dist/modules/agent-tasks/index.d.ts.map +1 -1
  41. package/dist/modules/agent-tasks/index.js +3 -1
  42. package/dist/modules/agent-tasks/index.js.map +1 -1
  43. package/dist/modules/auth/index.d.ts.map +1 -1
  44. package/dist/modules/auth/index.js +10 -3
  45. package/dist/modules/auth/index.js.map +1 -1
  46. package/dist/modules/auth/repository.d.ts +13 -1
  47. package/dist/modules/auth/repository.d.ts.map +1 -1
  48. package/dist/modules/auth/repository.js +32 -1
  49. package/dist/modules/auth/repository.js.map +1 -1
  50. package/dist/modules/auth/routes.d.ts.map +1 -1
  51. package/dist/modules/auth/routes.js +29 -6
  52. package/dist/modules/auth/routes.js.map +1 -1
  53. package/dist/modules/auth/schema.d.ts +130 -0
  54. package/dist/modules/auth/schema.d.ts.map +1 -1
  55. package/dist/modules/auth/schema.js +16 -0
  56. package/dist/modules/auth/schema.js.map +1 -1
  57. package/dist/modules/auth/service.d.ts +7 -1
  58. package/dist/modules/auth/service.d.ts.map +1 -1
  59. package/dist/modules/auth/service.js +53 -4
  60. package/dist/modules/auth/service.js.map +1 -1
  61. package/dist/modules/deals/repository.d.ts.map +1 -1
  62. package/dist/modules/deals/repository.js +1 -0
  63. package/dist/modules/deals/repository.js.map +1 -1
  64. package/dist/modules/decisions/repository.d.ts.map +1 -1
  65. package/dist/modules/decisions/repository.js +8 -7
  66. package/dist/modules/decisions/repository.js.map +1 -1
  67. package/dist/modules/forms/index.d.ts.map +1 -1
  68. package/dist/modules/forms/index.js +6 -1
  69. package/dist/modules/forms/index.js.map +1 -1
  70. package/dist/modules/forms/publicRoutes.d.ts +3 -0
  71. package/dist/modules/forms/publicRoutes.d.ts.map +1 -1
  72. package/dist/modules/forms/publicRoutes.js +10 -4
  73. package/dist/modules/forms/publicRoutes.js.map +1 -1
  74. package/dist/modules/forms/submission.d.ts +3 -0
  75. package/dist/modules/forms/submission.d.ts.map +1 -1
  76. package/dist/modules/forms/submission.js +6 -0
  77. package/dist/modules/forms/submission.js.map +1 -1
  78. package/dist/modules/opportunities/repository.d.ts.map +1 -1
  79. package/dist/modules/opportunities/repository.js +1 -0
  80. package/dist/modules/opportunities/repository.js.map +1 -1
  81. package/dist/modules/partnerships/repository.d.ts.map +1 -1
  82. package/dist/modules/partnerships/repository.js +1 -0
  83. package/dist/modules/partnerships/repository.js.map +1 -1
  84. package/dist/modules/raises/repository.d.ts.map +1 -1
  85. package/dist/modules/raises/repository.js +1 -0
  86. package/dist/modules/raises/repository.js.map +1 -1
  87. package/dist/modules/rate-limit/middleware.d.ts +4 -4
  88. package/dist/modules/rate-limit/middleware.d.ts.map +1 -1
  89. package/dist/modules/rate-limit/middleware.js +57 -11
  90. package/dist/modules/rate-limit/middleware.js.map +1 -1
  91. package/dist/modules/webhooks/delivery.d.ts +5 -2
  92. package/dist/modules/webhooks/delivery.d.ts.map +1 -1
  93. package/dist/modules/webhooks/delivery.js +5 -2
  94. package/dist/modules/webhooks/delivery.js.map +1 -1
  95. package/dist/modules/webhooks/index.d.ts.map +1 -1
  96. package/dist/modules/webhooks/index.js +3 -1
  97. package/dist/modules/webhooks/index.js.map +1 -1
  98. package/dist/modules/workspace/idempotencyMiddleware.d.ts.map +1 -1
  99. package/dist/modules/workspace/idempotencyMiddleware.js +14 -1
  100. package/dist/modules/workspace/idempotencyMiddleware.js.map +1 -1
  101. package/dist/modules/workspace/index.d.ts.map +1 -1
  102. package/dist/modules/workspace/index.js +3 -0
  103. package/dist/modules/workspace/index.js.map +1 -1
  104. package/dist/modules/workspace/repository.d.ts +1 -1
  105. package/dist/modules/workspace/repository.d.ts.map +1 -1
  106. package/dist/modules/workspace/repository.js +15 -9
  107. package/dist/modules/workspace/repository.js.map +1 -1
  108. package/dist/modules/workspace/routes.d.ts +0 -4
  109. package/dist/modules/workspace/routes.d.ts.map +1 -1
  110. package/dist/modules/workspace/routes.js +2 -7
  111. package/dist/modules/workspace/routes.js.map +1 -1
  112. package/dist/modules/workspace/service.d.ts +4 -2
  113. package/dist/modules/workspace/service.d.ts.map +1 -1
  114. package/dist/modules/workspace/service.js +42 -17
  115. package/dist/modules/workspace/service.js.map +1 -1
  116. package/dist/modules/workspace/tools.d.ts.map +1 -1
  117. package/dist/modules/workspace/tools.js +6 -7
  118. package/dist/modules/workspace/tools.js.map +1 -1
  119. package/dist/operator.d.ts +40 -0
  120. package/dist/operator.d.ts.map +1 -0
  121. package/dist/operator.js +50 -0
  122. package/dist/operator.js.map +1 -0
  123. package/dist/runtime/module.d.ts +25 -1
  124. package/dist/runtime/module.d.ts.map +1 -1
  125. package/dist/runtime/registry.d.ts +16 -1
  126. package/dist/runtime/registry.d.ts.map +1 -1
  127. package/dist/runtime/registry.js +34 -0
  128. package/dist/runtime/registry.js.map +1 -1
  129. package/dist/testing/client.d.ts +3 -1
  130. package/dist/testing/client.d.ts.map +1 -1
  131. package/dist/testing/client.js +19 -2
  132. package/dist/testing/client.js.map +1 -1
  133. package/dist/testing/environment.d.ts +2 -0
  134. package/dist/testing/environment.d.ts.map +1 -1
  135. package/dist/testing/environment.js +3 -0
  136. package/dist/testing/environment.js.map +1 -1
  137. package/dist/webBundle.d.ts +8 -0
  138. package/dist/webBundle.d.ts.map +1 -1
  139. package/dist/webBundle.js +9 -7
  140. package/dist/webBundle.js.map +1 -1
  141. package/migrations/0018_hard_mystique.sql +12 -0
  142. package/migrations/meta/0018_snapshot.json +5113 -0
  143. package/migrations/meta/_journal.json +8 -1
  144. package/package.json +4 -2
  145. package/src/app.ts +16 -0
  146. package/src/index.ts +9 -1
  147. package/src/lib/appUrl.ts +64 -0
  148. package/src/lib/clientIp.ts +41 -0
  149. package/src/lib/config.ts +30 -12
  150. package/src/lib/egress.ts +149 -0
  151. package/src/lib/email.ts +75 -7
  152. package/src/lib/ids.ts +2 -0
  153. package/src/lib/rateLimit.ts +16 -4
  154. package/src/modules/agent-tasks/dispatch.ts +9 -2
  155. package/src/modules/agent-tasks/index.ts +3 -1
  156. package/src/modules/auth/index.ts +12 -5
  157. package/src/modules/auth/repository.ts +56 -1
  158. package/src/modules/auth/routes.ts +34 -6
  159. package/src/modules/auth/schema.ts +17 -0
  160. package/src/modules/auth/service.ts +78 -5
  161. package/src/modules/deals/repository.ts +1 -0
  162. package/src/modules/decisions/repository.ts +8 -7
  163. package/src/modules/forms/index.ts +6 -1
  164. package/src/modules/forms/publicRoutes.ts +14 -4
  165. package/src/modules/forms/submission.ts +10 -0
  166. package/src/modules/opportunities/repository.ts +1 -0
  167. package/src/modules/partnerships/repository.ts +1 -0
  168. package/src/modules/raises/repository.ts +1 -0
  169. package/src/modules/rate-limit/middleware.ts +74 -11
  170. package/src/modules/webhooks/delivery.ts +10 -2
  171. package/src/modules/webhooks/index.ts +3 -1
  172. package/src/modules/workspace/idempotencyMiddleware.ts +17 -1
  173. package/src/modules/workspace/index.ts +4 -0
  174. package/src/modules/workspace/repository.ts +15 -8
  175. package/src/modules/workspace/routes.ts +0 -8
  176. package/src/modules/workspace/service.ts +53 -20
  177. package/src/modules/workspace/tools.ts +7 -21
  178. package/src/runtime/module.ts +25 -1
  179. package/src/runtime/registry.ts +59 -1
  180. package/src/testing/client.ts +22 -2
  181. package/src/testing/environment.ts +4 -0
  182. package/src/webBundle.ts +21 -7
@@ -39,10 +39,25 @@ const UNAUTHENTICATED_AUTH_PATHS: ReadonlySet<string> = new Set([
39
39
  '/v1/auth/login',
40
40
  '/v1/auth/password-reset',
41
41
  '/v1/auth/password-reset/confirm',
42
+ '/v1/auth/verify-email/confirm',
42
43
  ])
43
44
 
44
- function isFormSubmitRoute(context: Context): boolean {
45
- return context.req.method === 'POST' && context.req.path.endsWith('/submit')
45
+ /** The one path in the set that also carries a per-account budget. */
46
+ const LOGIN_PATH = '/v1/auth/login'
47
+
48
+ /**
49
+ * The public form routes that carry the forms budget: the submit POST and the
50
+ * embed GET. Both do database work for an unauthenticated caller, so both are
51
+ * metered by IP. Every other path under the public prefix passes through.
52
+ */
53
+ function isMeteredFormRoute(context: Context): boolean {
54
+ const path = context.req.path
55
+
56
+ if (context.req.method === 'POST' && path.endsWith('/submit')) {
57
+ return true
58
+ }
59
+
60
+ return context.req.method === 'GET' && path.endsWith('/embed')
46
61
  }
47
62
 
48
63
  /** Floors `now` to the start of its fixed window, so every caller in the same window shares one row. */
@@ -51,12 +66,45 @@ function windowStart(now: Date, windowMs: number): Date {
51
66
  }
52
67
 
53
68
  /**
54
- * The largest configured window across all three budgets. A bucket cannot be
55
- * expired before its own window elapses, so this is a safe cutoff for
56
- * pruning regardless of which budget created the row.
69
+ * The largest configured window across every budget. A bucket cannot be expired
70
+ * before its own window elapses, so this is a safe cutoff for pruning regardless
71
+ * of which budget created the row. The `login-account` window is the longest by
72
+ * default, so leaving it out would prune a live account bucket early.
57
73
  */
58
74
  function maxWindowMs(config: RateLimitConfig): number {
59
- return Math.max(config.forms.windowMs, config.auth.windowMs, config.api.windowMs)
75
+ return Math.max(
76
+ config.forms.windowMs,
77
+ config.auth.windowMs,
78
+ config.loginAccount.windowMs,
79
+ config.api.windowMs,
80
+ )
81
+ }
82
+
83
+ /**
84
+ * The email a login request names, normalised the way the auth service
85
+ * normalises it (`modules/auth/service.ts`), or undefined when the body carries
86
+ * no usable one.
87
+ *
88
+ * Reads the body through Hono's cache, so the login handler's own read of it
89
+ * later is unaffected. A malformed body resolves to undefined here and is left
90
+ * for the handler to reject.
91
+ */
92
+ async function readLoginEmail(context: Context): Promise<string | undefined> {
93
+ const body: unknown = await context.req.json().catch(() => undefined)
94
+
95
+ if (typeof body !== 'object' || body === null) {
96
+ return undefined
97
+ }
98
+
99
+ const email = (body as Record<string, unknown>).email
100
+
101
+ if (typeof email !== 'string') {
102
+ return undefined
103
+ }
104
+
105
+ const normalised = email.trim().toLowerCase()
106
+
107
+ return normalised.length === 0 ? undefined : normalised
60
108
  }
61
109
 
62
110
  /**
@@ -94,17 +142,17 @@ async function enforceBudget(
94
142
  }
95
143
 
96
144
  /**
97
- * Guards `POST /v1/public/forms/:key/submit`, keyed by the caller's IP.
145
+ * Guards the public form routes that reach the database, keyed by the caller's
146
+ * IP: the submit `POST` and the embed `GET`.
98
147
  *
99
- * Mount this **after** `PUBLIC_CORS` in `app.ts`. It only recognises the
100
- * submit route; every other path under the public prefix, including the embed
101
- * page itself, passes through untouched.
148
+ * Mount this **after** `PUBLIC_CORS` in `app.ts`. Every other path under the
149
+ * public prefix passes through untouched.
102
150
  */
103
151
  export function createFormSubmitRateLimitMiddleware(
104
152
  dependencies: RateLimitMiddlewareDependencies,
105
153
  ): MiddlewareHandler {
106
154
  return async (context, next) => {
107
- if (!isFormSubmitRoute(context)) {
155
+ if (!isMeteredFormRoute(context)) {
108
156
  await next()
109
157
  return
110
158
  }
@@ -146,6 +194,21 @@ export function createAuthAndApiRateLimitMiddleware(
146
194
  key: dependencies.resolveClientIp(context),
147
195
  budget: dependencies.config.auth,
148
196
  })
197
+
198
+ // A second budget for login, keyed on the account rather than the IP, so a
199
+ // distributed attack on one address is capped whatever IPs it comes from.
200
+ if (path === LOGIN_PATH) {
201
+ const email = await readLoginEmail(context)
202
+
203
+ if (email !== undefined) {
204
+ await enforceBudget(dependencies, context, {
205
+ scope: 'login-account',
206
+ key: email,
207
+ budget: dependencies.config.loginAccount,
208
+ })
209
+ }
210
+ }
211
+
149
212
  await next()
150
213
  return
151
214
  }
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod'
2
2
 
3
3
  import type { Database } from '../../lib/database.ts'
4
+ import type { EgressGuard } from '../../lib/egress.ts'
4
5
  import { describeThrown } from '../../lib/errors.ts'
5
6
  import type { IdFactory } from '../../lib/ids.ts'
6
7
  import type { Logger } from '../../lib/logger.ts'
@@ -115,11 +116,18 @@ export function sleepFor(milliseconds: number): Promise<void> {
115
116
  * The catch is broad because this is the process boundary: `fetch` rejects with
116
117
  * anything from a DNS failure to an abort, the set is open, and turning all of
117
118
  * it into one outcome is the port's whole job. Nothing is swallowed — the
118
- * reason reaches the log and the delivery is recorded as failed.
119
+ * reason reaches the log and the delivery is recorded as failed. A blocked
120
+ * private address (`egress.check`) reaches the same catch and is recorded the
121
+ * same way, as a failed attempt rather than a crash.
119
122
  */
120
- export function createHttpSender(fetchImplementation: typeof fetch = fetch): SendDelivery {
123
+ export function createHttpSender(
124
+ egress: EgressGuard,
125
+ fetchImplementation: typeof fetch = fetch,
126
+ ): SendDelivery {
121
127
  return async (request) => {
122
128
  try {
129
+ await egress.check(request.url)
130
+
123
131
  const response = await fetchImplementation(request.url, {
124
132
  method: 'POST',
125
133
  body: request.body,
@@ -1,3 +1,4 @@
1
+ import { createEgressGuard, egressConfigSchema } from '../../lib/egress.ts'
1
2
  import { createSecretCipher, secretEncryptionConfigSchema } from '../../lib/secrets.ts'
2
3
  import type { KelpieModule } from '../../runtime/module.ts'
3
4
  import {
@@ -49,6 +50,7 @@ export function createWebhooksModule(
49
50
  // should stop boot, not prune by the wrong window.
50
51
  const cipher = createSecretCipher(context.config(secretEncryptionConfigSchema))
51
52
  const retention = context.config(deliveryRetentionConfigSchema)
53
+ const egress = createEgressGuard(context.config(egressConfigSchema))
52
54
 
53
55
  const service = createWebhooksService({
54
56
  db: context.db,
@@ -64,7 +66,7 @@ export function createWebhooksModule(
64
66
  createId: context.createId,
65
67
  now: context.now,
66
68
  cipher,
67
- send: options.send ?? createHttpSender(),
69
+ send: options.send ?? createHttpSender(egress),
68
70
  sleep: options.sleep ?? sleepFor,
69
71
  retentionDays: retention.WEBHOOK_DELIVERY_RETENTION_DAYS,
70
72
  log: context.log,
@@ -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,3 +1,4 @@
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
4
  import { SEATS_LIMIT } from './capabilities.ts'
@@ -20,6 +21,8 @@ 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)
24
27
 
25
28
  // Answers `module.<id>` for whatever a config override left undecided
@@ -44,6 +47,7 @@ export function createWorkspaceModule(migrationsDirectory: string): KelpieModule
44
47
  createId: context.createId,
45
48
  now: context.now,
46
49
  entitlements: context.entitlements,
50
+ appBaseUrl: config.APP_BASE_URL,
47
51
  toggleableModuleIds: context.moduleCatalog
48
52
  .filter((entry) => !entry.structural)
49
53
  .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
- const [row] = await db.select({ total: count() }).from(source.table).where(eq(source.column, memberId))
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, 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,