@kelpie/server 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +14 -0
- package/dist/app.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/appUrl.d.ts +27 -0
- package/dist/lib/appUrl.d.ts.map +1 -0
- package/dist/lib/appUrl.js +53 -0
- package/dist/lib/appUrl.js.map +1 -0
- package/dist/lib/clientIp.d.ts +24 -0
- package/dist/lib/clientIp.d.ts.map +1 -0
- package/dist/lib/clientIp.js +34 -0
- package/dist/lib/clientIp.js.map +1 -0
- package/dist/lib/config.d.ts +11 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +23 -12
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/egress.d.ts +44 -0
- package/dist/lib/egress.d.ts.map +1 -0
- package/dist/lib/egress.js +111 -0
- package/dist/lib/egress.js.map +1 -0
- package/dist/lib/email.d.ts +53 -9
- package/dist/lib/email.d.ts.map +1 -1
- package/dist/lib/email.js +49 -4
- package/dist/lib/email.js.map +1 -1
- package/dist/lib/ids.d.ts +2 -0
- package/dist/lib/ids.d.ts.map +1 -1
- package/dist/lib/ids.js +2 -0
- package/dist/lib/ids.js.map +1 -1
- package/dist/lib/rateLimit.d.ts +12 -4
- package/dist/lib/rateLimit.d.ts.map +1 -1
- package/dist/lib/rateLimit.js +6 -0
- package/dist/lib/rateLimit.js.map +1 -1
- package/dist/modules/agent-tasks/dispatch.d.ts +4 -2
- package/dist/modules/agent-tasks/dispatch.d.ts.map +1 -1
- package/dist/modules/agent-tasks/dispatch.js +4 -2
- package/dist/modules/agent-tasks/dispatch.js.map +1 -1
- package/dist/modules/agent-tasks/index.d.ts.map +1 -1
- package/dist/modules/agent-tasks/index.js +3 -1
- package/dist/modules/agent-tasks/index.js.map +1 -1
- package/dist/modules/auth/index.d.ts.map +1 -1
- package/dist/modules/auth/index.js +10 -3
- package/dist/modules/auth/index.js.map +1 -1
- package/dist/modules/auth/repository.d.ts +13 -1
- package/dist/modules/auth/repository.d.ts.map +1 -1
- package/dist/modules/auth/repository.js +32 -1
- package/dist/modules/auth/repository.js.map +1 -1
- package/dist/modules/auth/routes.d.ts.map +1 -1
- package/dist/modules/auth/routes.js +29 -6
- package/dist/modules/auth/routes.js.map +1 -1
- package/dist/modules/auth/schema.d.ts +130 -0
- package/dist/modules/auth/schema.d.ts.map +1 -1
- package/dist/modules/auth/schema.js +16 -0
- package/dist/modules/auth/schema.js.map +1 -1
- package/dist/modules/auth/service.d.ts +7 -1
- package/dist/modules/auth/service.d.ts.map +1 -1
- package/dist/modules/auth/service.js +53 -4
- package/dist/modules/auth/service.js.map +1 -1
- package/dist/modules/deals/repository.d.ts.map +1 -1
- package/dist/modules/deals/repository.js +1 -0
- package/dist/modules/deals/repository.js.map +1 -1
- package/dist/modules/decisions/repository.d.ts.map +1 -1
- package/dist/modules/decisions/repository.js +8 -7
- package/dist/modules/decisions/repository.js.map +1 -1
- package/dist/modules/forms/index.d.ts.map +1 -1
- package/dist/modules/forms/index.js +6 -1
- package/dist/modules/forms/index.js.map +1 -1
- package/dist/modules/forms/publicRoutes.d.ts +3 -0
- package/dist/modules/forms/publicRoutes.d.ts.map +1 -1
- package/dist/modules/forms/publicRoutes.js +10 -4
- package/dist/modules/forms/publicRoutes.js.map +1 -1
- package/dist/modules/forms/submission.d.ts +3 -0
- package/dist/modules/forms/submission.d.ts.map +1 -1
- package/dist/modules/forms/submission.js +6 -0
- package/dist/modules/forms/submission.js.map +1 -1
- package/dist/modules/opportunities/repository.d.ts.map +1 -1
- package/dist/modules/opportunities/repository.js +1 -0
- package/dist/modules/opportunities/repository.js.map +1 -1
- package/dist/modules/partnerships/repository.d.ts.map +1 -1
- package/dist/modules/partnerships/repository.js +1 -0
- package/dist/modules/partnerships/repository.js.map +1 -1
- package/dist/modules/raises/repository.d.ts.map +1 -1
- package/dist/modules/raises/repository.js +1 -0
- package/dist/modules/raises/repository.js.map +1 -1
- package/dist/modules/rate-limit/middleware.d.ts +4 -4
- package/dist/modules/rate-limit/middleware.d.ts.map +1 -1
- package/dist/modules/rate-limit/middleware.js +57 -11
- package/dist/modules/rate-limit/middleware.js.map +1 -1
- package/dist/modules/webhooks/delivery.d.ts +5 -2
- package/dist/modules/webhooks/delivery.d.ts.map +1 -1
- package/dist/modules/webhooks/delivery.js +5 -2
- package/dist/modules/webhooks/delivery.js.map +1 -1
- package/dist/modules/webhooks/index.d.ts.map +1 -1
- package/dist/modules/webhooks/index.js +3 -1
- package/dist/modules/webhooks/index.js.map +1 -1
- package/dist/modules/workspace/idempotencyMiddleware.d.ts.map +1 -1
- package/dist/modules/workspace/idempotencyMiddleware.js +14 -1
- package/dist/modules/workspace/idempotencyMiddleware.js.map +1 -1
- package/dist/modules/workspace/index.d.ts.map +1 -1
- package/dist/modules/workspace/index.js +3 -0
- package/dist/modules/workspace/index.js.map +1 -1
- package/dist/modules/workspace/repository.d.ts +1 -1
- package/dist/modules/workspace/repository.d.ts.map +1 -1
- package/dist/modules/workspace/repository.js +15 -9
- package/dist/modules/workspace/repository.js.map +1 -1
- package/dist/modules/workspace/routes.d.ts +0 -4
- package/dist/modules/workspace/routes.d.ts.map +1 -1
- package/dist/modules/workspace/routes.js +2 -7
- package/dist/modules/workspace/routes.js.map +1 -1
- package/dist/modules/workspace/service.d.ts +4 -2
- package/dist/modules/workspace/service.d.ts.map +1 -1
- package/dist/modules/workspace/service.js +42 -17
- package/dist/modules/workspace/service.js.map +1 -1
- package/dist/modules/workspace/tools.d.ts.map +1 -1
- package/dist/modules/workspace/tools.js +6 -7
- package/dist/modules/workspace/tools.js.map +1 -1
- package/dist/operator.d.ts +40 -0
- package/dist/operator.d.ts.map +1 -0
- package/dist/operator.js +50 -0
- package/dist/operator.js.map +1 -0
- package/dist/runtime/module.d.ts +25 -1
- package/dist/runtime/module.d.ts.map +1 -1
- package/dist/runtime/registry.d.ts +16 -1
- package/dist/runtime/registry.d.ts.map +1 -1
- package/dist/runtime/registry.js +34 -0
- package/dist/runtime/registry.js.map +1 -1
- package/dist/testing/client.d.ts +3 -1
- package/dist/testing/client.d.ts.map +1 -1
- package/dist/testing/client.js +19 -2
- package/dist/testing/client.js.map +1 -1
- package/dist/testing/environment.d.ts +2 -0
- package/dist/testing/environment.d.ts.map +1 -1
- package/dist/testing/environment.js +3 -0
- package/dist/testing/environment.js.map +1 -1
- package/dist/webBundle.d.ts +8 -0
- package/dist/webBundle.d.ts.map +1 -1
- package/dist/webBundle.js +9 -7
- package/dist/webBundle.js.map +1 -1
- package/migrations/0018_hard_mystique.sql +12 -0
- package/migrations/meta/0018_snapshot.json +5113 -0
- package/migrations/meta/_journal.json +8 -1
- package/package.json +4 -2
- package/src/app.ts +16 -0
- package/src/index.ts +9 -1
- package/src/lib/appUrl.ts +64 -0
- package/src/lib/clientIp.ts +41 -0
- package/src/lib/config.ts +30 -12
- package/src/lib/egress.ts +149 -0
- package/src/lib/email.ts +75 -7
- package/src/lib/ids.ts +2 -0
- package/src/lib/rateLimit.ts +16 -4
- package/src/modules/agent-tasks/dispatch.ts +9 -2
- package/src/modules/agent-tasks/index.ts +3 -1
- package/src/modules/auth/index.ts +12 -5
- package/src/modules/auth/repository.ts +56 -1
- package/src/modules/auth/routes.ts +34 -6
- package/src/modules/auth/schema.ts +17 -0
- package/src/modules/auth/service.ts +78 -5
- package/src/modules/deals/repository.ts +1 -0
- package/src/modules/decisions/repository.ts +8 -7
- package/src/modules/forms/index.ts +6 -1
- package/src/modules/forms/publicRoutes.ts +14 -4
- package/src/modules/forms/submission.ts +10 -0
- package/src/modules/opportunities/repository.ts +1 -0
- package/src/modules/partnerships/repository.ts +1 -0
- package/src/modules/raises/repository.ts +1 -0
- package/src/modules/rate-limit/middleware.ts +74 -11
- package/src/modules/webhooks/delivery.ts +10 -2
- package/src/modules/webhooks/index.ts +3 -1
- package/src/modules/workspace/idempotencyMiddleware.ts +17 -1
- package/src/modules/workspace/index.ts +4 -0
- package/src/modules/workspace/repository.ts +15 -8
- package/src/modules/workspace/routes.ts +0 -8
- package/src/modules/workspace/service.ts +53 -20
- package/src/modules/workspace/tools.ts +7 -21
- package/src/runtime/module.ts +25 -1
- package/src/runtime/registry.ts +59 -1
- package/src/testing/client.ts +22 -2
- package/src/testing/environment.ts +4 -0
- package/src/webBundle.ts +21 -7
|
@@ -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
|
-
|
|
45
|
-
|
|
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
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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(
|
|
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
|
|
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`.
|
|
100
|
-
*
|
|
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 (!
|
|
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(
|
|
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
|
-
|
|
150
|
+
// A member id is globally unique, so the workspace term is defence in
|
|
151
|
+
// depth rather than the thing that scopes the count. It keeps the query
|
|
152
|
+
// safe on its own instead of by that invariant.
|
|
153
|
+
const [row] = await db
|
|
154
|
+
.select({ total: count() })
|
|
155
|
+
.from(source.table)
|
|
156
|
+
.where(and(eq(source.column, memberId), eq(source.workspaceId, workspaceId)))
|
|
150
157
|
|
|
151
158
|
return { type: source.type, count: row?.total ?? 0 }
|
|
152
159
|
}),
|
|
@@ -34,11 +34,6 @@ export const updateBody = z
|
|
|
34
34
|
export const inviteBody = z.object({
|
|
35
35
|
email: z.string().min(1),
|
|
36
36
|
role: z.enum(INVITABLE_ROLES),
|
|
37
|
-
invite_url_template: z.string().min(1).includes('{token}'),
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
export const resendBody = z.object({
|
|
41
|
-
invite_url_template: z.string().min(1).includes('{token}'),
|
|
42
37
|
})
|
|
43
38
|
|
|
44
39
|
export const memberRoleBody = z.object({ role: z.enum(MEMBER_ROLES) })
|
|
@@ -187,7 +182,6 @@ export function mountWorkspaceRoutes(router: Hono, dependencies: WorkspaceRoutes
|
|
|
187
182
|
context.req.param('id'),
|
|
188
183
|
body.email,
|
|
189
184
|
body.role,
|
|
190
|
-
body.invite_url_template,
|
|
191
185
|
)
|
|
192
186
|
|
|
193
187
|
return context.json(inviteResponse(invite), 201)
|
|
@@ -203,12 +197,10 @@ export function mountWorkspaceRoutes(router: Hono, dependencies: WorkspaceRoutes
|
|
|
203
197
|
})
|
|
204
198
|
|
|
205
199
|
router.post('/workspaces/:id/invites/:inviteId/resend', async (context) => {
|
|
206
|
-
const body = await readBody(context, resendBody)
|
|
207
200
|
const invite = await dependencies.service.resendInvite(
|
|
208
201
|
await requireActor(context),
|
|
209
202
|
context.req.param('id'),
|
|
210
203
|
context.req.param('inviteId'),
|
|
211
|
-
body.invite_url_template,
|
|
212
204
|
)
|
|
213
205
|
|
|
214
206
|
return context.json(inviteResponse(invite))
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { APP_LINK_PATHS, buildAppLink } from '../../lib/appUrl.ts'
|
|
1
2
|
import { UNIQUE_VIOLATION, isReferenceViolation, postgresErrorCode } from '../../lib/database.ts'
|
|
2
3
|
import type { Database } from '../../lib/database.ts'
|
|
3
4
|
import type { EmailSender } from '../../lib/email.ts'
|
|
@@ -36,6 +37,8 @@ export interface WorkspaceDependencies {
|
|
|
36
37
|
readonly createId: IdFactory
|
|
37
38
|
readonly now: () => Date
|
|
38
39
|
readonly entitlements: EntitlementRegistry
|
|
40
|
+
/** The deployment's own base URL. Every invitation link is built from it. */
|
|
41
|
+
readonly appBaseUrl: string
|
|
39
42
|
readonly newToken?: () => string
|
|
40
43
|
/** Every non-structural module id (`runtime/module.ts`'s `moduleCatalog`), in assembly order. */
|
|
41
44
|
readonly toggleableModuleIds: readonly string[]
|
|
@@ -117,10 +120,10 @@ export interface WorkspaceService {
|
|
|
117
120
|
/** Changes a member's role, or transfers ownership when `role` is `owner`. */
|
|
118
121
|
setMemberRole(actor: Actor, workspaceId: string, memberId: string, role: MemberRole): Promise<MemberView>
|
|
119
122
|
removeMember(actor: Actor, workspaceId: string, memberId: string): Promise<void>
|
|
120
|
-
invite(actor: Actor, workspaceId: string, email: string, role: InvitableRole
|
|
123
|
+
invite(actor: Actor, workspaceId: string, email: string, role: InvitableRole): Promise<InviteView>
|
|
121
124
|
listInvites(actor: Actor, workspaceId: string): Promise<readonly InviteView[]>
|
|
122
125
|
/** Issues a fresh token and expiry for an invitation, and emails it again. */
|
|
123
|
-
resendInvite(actor: Actor, workspaceId: string, inviteId: string
|
|
126
|
+
resendInvite(actor: Actor, workspaceId: string, inviteId: string): Promise<InviteView>
|
|
124
127
|
revokeInvite(actor: Actor, workspaceId: string, inviteId: string): Promise<void>
|
|
125
128
|
/** Joins the invited workspace as the calling account. */
|
|
126
129
|
acceptInvite(actor: SessionActor, token: string): Promise<WorkspaceView>
|
|
@@ -292,6 +295,26 @@ export function createWorkspaceService(dependencies: WorkspaceDependencies): Wor
|
|
|
292
295
|
}
|
|
293
296
|
}
|
|
294
297
|
|
|
298
|
+
/**
|
|
299
|
+
* Refuses an actor whose account has not verified its email address.
|
|
300
|
+
*
|
|
301
|
+
* Creating a workspace is the one action that turns a bare account into
|
|
302
|
+
* something with real access (`schema.md`), so it is where the gate lives:
|
|
303
|
+
* every CRM endpoint already refuses an actor with no workspace, which means
|
|
304
|
+
* blocking this one call is enough to block the rest.
|
|
305
|
+
*/
|
|
306
|
+
async function requireVerifiedEmail(actor: SessionActor): Promise<void> {
|
|
307
|
+
const user = await authRepository.findUserById(dependencies.db, actor.userId)
|
|
308
|
+
|
|
309
|
+
if (user === undefined) {
|
|
310
|
+
throw new Error(`Session ${actor.sessionId} outlived the user row behind it`)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (user.emailVerifiedAt === null) {
|
|
314
|
+
throw new AppError('forbidden', 'Verify your email before creating a workspace')
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
295
318
|
/**
|
|
296
319
|
* The membership being acted on.
|
|
297
320
|
*
|
|
@@ -309,11 +332,13 @@ export function createWorkspaceService(dependencies: WorkspaceDependencies): Wor
|
|
|
309
332
|
return target
|
|
310
333
|
}
|
|
311
334
|
|
|
312
|
-
function sendInviteEmail(to: string, token: string
|
|
335
|
+
function sendInviteEmail(to: string, token: string): Promise<void> {
|
|
336
|
+
const link = buildAppLink(dependencies.appBaseUrl, APP_LINK_PATHS.inviteAccept, token)
|
|
337
|
+
|
|
313
338
|
return dependencies.email.send({
|
|
314
339
|
to,
|
|
315
340
|
subject: 'You have been invited to a Kelpie workspace',
|
|
316
|
-
body: `Accept the invitation within seven days:\n\n${
|
|
341
|
+
body: `Accept the invitation within seven days:\n\n${link}`,
|
|
317
342
|
})
|
|
318
343
|
}
|
|
319
344
|
|
|
@@ -370,6 +395,8 @@ export function createWorkspaceService(dependencies: WorkspaceDependencies): Wor
|
|
|
370
395
|
|
|
371
396
|
return {
|
|
372
397
|
async create(actor, input) {
|
|
398
|
+
await requireVerifiedEmail(actor)
|
|
399
|
+
|
|
373
400
|
const now = dependencies.now()
|
|
374
401
|
const workspaceId = dependencies.createId('workspace')
|
|
375
402
|
const memberId = dependencies.createId('teamMember')
|
|
@@ -559,7 +586,7 @@ export function createWorkspaceService(dependencies: WorkspaceDependencies): Wor
|
|
|
559
586
|
// `schema.md`: removing a member is restricted while they own records.
|
|
560
587
|
// Reported before the delete so every referencing type can be named, which
|
|
561
588
|
// is what the caller needs to know what to reassign.
|
|
562
|
-
const references = await repository.countMemberReferences(dependencies.db, target.id)
|
|
589
|
+
const references = await repository.countMemberReferences(dependencies.db, workspaceId, target.id)
|
|
563
590
|
|
|
564
591
|
if (references.length > 0) {
|
|
565
592
|
throw AppError.conflict(
|
|
@@ -589,7 +616,7 @@ export function createWorkspaceService(dependencies: WorkspaceDependencies): Wor
|
|
|
589
616
|
}
|
|
590
617
|
},
|
|
591
618
|
|
|
592
|
-
async invite(actor, workspaceId, email, role
|
|
619
|
+
async invite(actor, workspaceId, email, role) {
|
|
593
620
|
const inviter = await requireMembership(actor, workspaceId, 'admin')
|
|
594
621
|
const address = email.trim().toLowerCase()
|
|
595
622
|
const now = dependencies.now()
|
|
@@ -637,7 +664,7 @@ export function createWorkspaceService(dependencies: WorkspaceDependencies): Wor
|
|
|
637
664
|
})
|
|
638
665
|
|
|
639
666
|
// Sent after commit, so a rolled-back invite never reaches an inbox.
|
|
640
|
-
await sendInviteEmail(invite.email, token
|
|
667
|
+
await sendInviteEmail(invite.email, token)
|
|
641
668
|
|
|
642
669
|
return toInviteView(invite, now)
|
|
643
670
|
},
|
|
@@ -650,7 +677,7 @@ export function createWorkspaceService(dependencies: WorkspaceDependencies): Wor
|
|
|
650
677
|
return records.map((record) => toInviteView(record, now))
|
|
651
678
|
},
|
|
652
679
|
|
|
653
|
-
async resendInvite(actor, workspaceId, inviteId
|
|
680
|
+
async resendInvite(actor, workspaceId, inviteId) {
|
|
654
681
|
await requireMembership(actor, workspaceId, 'admin')
|
|
655
682
|
const invite = await requireInvite(workspaceId, inviteId)
|
|
656
683
|
|
|
@@ -674,7 +701,7 @@ export function createWorkspaceService(dependencies: WorkspaceDependencies): Wor
|
|
|
674
701
|
throw AppError.notFound('Invitation not found')
|
|
675
702
|
}
|
|
676
703
|
|
|
677
|
-
await sendInviteEmail(updated.email, token
|
|
704
|
+
await sendInviteEmail(updated.email, token)
|
|
678
705
|
|
|
679
706
|
return toInviteView(updated, now)
|
|
680
707
|
},
|
|
@@ -702,10 +729,10 @@ export function createWorkspaceService(dependencies: WorkspaceDependencies): Wor
|
|
|
702
729
|
actor.userId,
|
|
703
730
|
)
|
|
704
731
|
|
|
705
|
-
// The invitation is left alone
|
|
706
|
-
//
|
|
707
|
-
//
|
|
708
|
-
//
|
|
732
|
+
// The invitation is left alone rather than deleted: the caller already
|
|
733
|
+
// belongs, so this token cannot be theirs, and deleting somebody else's
|
|
734
|
+
// invitation because a different account presented it would strand the
|
|
735
|
+
// person it was actually sent to.
|
|
709
736
|
if (existing !== undefined) {
|
|
710
737
|
throw AppError.conflict('You already belong to that workspace')
|
|
711
738
|
}
|
|
@@ -716,6 +743,14 @@ export function createWorkspaceService(dependencies: WorkspaceDependencies): Wor
|
|
|
716
743
|
throw new Error(`Session ${actor.sessionId} outlived the user row behind it`)
|
|
717
744
|
}
|
|
718
745
|
|
|
746
|
+
// An invitation names an address, not a person. Accepting it with a
|
|
747
|
+
// different account would let a leaked or forwarded token seat anyone,
|
|
748
|
+
// and would let that account claim credit for controlling an address
|
|
749
|
+
// it never proved control of.
|
|
750
|
+
if (joiner.email !== invite.email) {
|
|
751
|
+
throw AppError.unauthorized('That invitation is invalid or has expired')
|
|
752
|
+
}
|
|
753
|
+
|
|
719
754
|
// Ownership is never invited, only created or transferred. The column's
|
|
720
755
|
// check constraint says the same; this is the service-side half of it.
|
|
721
756
|
const role: MemberRole = invite.role === 'admin' ? 'admin' : 'member'
|
|
@@ -740,14 +775,12 @@ export function createWorkspaceService(dependencies: WorkspaceDependencies): Wor
|
|
|
740
775
|
throw error
|
|
741
776
|
}
|
|
742
777
|
|
|
743
|
-
|
|
778
|
+
// A real invite link already proves control of the invited address, so
|
|
779
|
+
// accepting one satisfies the same gate a verification link would.
|
|
780
|
+
// A no-op if the account was already verified.
|
|
781
|
+
await authRepository.markEmailVerified(tx, actor.userId, now)
|
|
744
782
|
|
|
745
|
-
|
|
746
|
-
// has just killed: accepting one is now the refusal above, and until it
|
|
747
|
-
// expires it holds a seat and lists them beside their own membership.
|
|
748
|
-
// Accepting a token addressed to somebody else is how one survives the
|
|
749
|
-
// line before it.
|
|
750
|
-
await repository.deleteInvitesForEmail(tx, invite.workspaceId, joiner.email)
|
|
783
|
+
await repository.deleteInvite(tx, invite.id)
|
|
751
784
|
|
|
752
785
|
await authRepository.setActiveWorkspace(tx, actor.sessionId, invite.workspaceId)
|
|
753
786
|
|
|
@@ -3,7 +3,7 @@ import { z } from 'zod'
|
|
|
3
3
|
import { requireWorkspaceId } from '../../lib/actor.ts'
|
|
4
4
|
import type { McpToolRegistry } from '../../runtime/module.ts'
|
|
5
5
|
import { deleteResult, idArg } from '../crudTools.ts'
|
|
6
|
-
import { inviteBody, inviteResponse, memberResponse, memberRoleBody,
|
|
6
|
+
import { inviteBody, inviteResponse, memberResponse, memberRoleBody, updateBody, workspaceResponse } from './routes.ts'
|
|
7
7
|
import type { WorkspaceService } from './service.ts'
|
|
8
8
|
|
|
9
9
|
/**
|
|
@@ -82,19 +82,12 @@ export function registerWorkspaceTools(mcp: McpToolRegistry, service: WorkspaceS
|
|
|
82
82
|
mcp.tool({
|
|
83
83
|
name: 'workspace_invites_create',
|
|
84
84
|
description:
|
|
85
|
-
'Invite somebody by email and send them the invitation.
|
|
86
|
-
'
|
|
87
|
-
'Mirrors POST /v1/workspaces/{id}/invites.',
|
|
85
|
+
'Invite somebody by email and send them the invitation. The link is built from the ' +
|
|
86
|
+
'deployment\'s own base URL. Admin only. Mirrors POST /v1/workspaces/{id}/invites.',
|
|
88
87
|
inputSchema: inviteBody,
|
|
89
88
|
invoke: async (body, actor) =>
|
|
90
89
|
inviteResponse(
|
|
91
|
-
await service.invite(
|
|
92
|
-
actor,
|
|
93
|
-
requireWorkspaceId(actor),
|
|
94
|
-
body.email,
|
|
95
|
-
body.role,
|
|
96
|
-
body.invite_url_template,
|
|
97
|
-
),
|
|
90
|
+
await service.invite(actor, requireWorkspaceId(actor), body.email, body.role),
|
|
98
91
|
),
|
|
99
92
|
})
|
|
100
93
|
|
|
@@ -114,16 +107,9 @@ export function registerWorkspaceTools(mcp: McpToolRegistry, service: WorkspaceS
|
|
|
114
107
|
description:
|
|
115
108
|
'Issue a fresh token and expiry for an invitation and email it again. Admin only. ' +
|
|
116
109
|
'Mirrors POST /v1/workspaces/{id}/invites/{inviteId}/resend.',
|
|
117
|
-
inputSchema:
|
|
118
|
-
invoke: async (
|
|
119
|
-
inviteResponse(
|
|
120
|
-
await service.resendInvite(
|
|
121
|
-
actor,
|
|
122
|
-
requireWorkspaceId(actor),
|
|
123
|
-
args.invite_id,
|
|
124
|
-
args.invite_url_template,
|
|
125
|
-
),
|
|
126
|
-
),
|
|
110
|
+
inputSchema: z.strictObject({ invite_id: idArg }),
|
|
111
|
+
invoke: async ({ invite_id: inviteId }, actor) =>
|
|
112
|
+
inviteResponse(await service.resendInvite(actor, requireWorkspaceId(actor), inviteId)),
|
|
127
113
|
})
|
|
128
114
|
|
|
129
115
|
mcp.tool({
|
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,
|