@kelpie/server 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/dist/app.d.ts +11 -0
  2. package/dist/app.d.ts.map +1 -1
  3. package/dist/app.js +27 -0
  4. package/dist/app.js.map +1 -1
  5. package/dist/index.d.ts +3 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +1 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/lib/config.d.ts +8 -0
  10. package/dist/lib/config.d.ts.map +1 -1
  11. package/dist/lib/config.js +5 -0
  12. package/dist/lib/config.js.map +1 -1
  13. package/dist/lib/errors.d.ts +1 -0
  14. package/dist/lib/errors.d.ts.map +1 -1
  15. package/dist/lib/errors.js +3 -0
  16. package/dist/lib/errors.js.map +1 -1
  17. package/dist/lib/ids.d.ts +5 -0
  18. package/dist/lib/ids.d.ts.map +1 -1
  19. package/dist/lib/ids.js +5 -0
  20. package/dist/lib/ids.js.map +1 -1
  21. package/dist/lib/rateLimit.d.ts +26 -0
  22. package/dist/lib/rateLimit.d.ts.map +1 -0
  23. package/dist/lib/rateLimit.js +26 -0
  24. package/dist/lib/rateLimit.js.map +1 -0
  25. package/dist/lib/securityHeaders.d.ts +3 -0
  26. package/dist/lib/securityHeaders.d.ts.map +1 -0
  27. package/dist/lib/securityHeaders.js +41 -0
  28. package/dist/lib/securityHeaders.js.map +1 -0
  29. package/dist/modules/import-export/drafts.d.ts.map +1 -1
  30. package/dist/modules/import-export/drafts.js +0 -1
  31. package/dist/modules/import-export/drafts.js.map +1 -1
  32. package/dist/modules/rate-limit/middleware.d.ts +42 -0
  33. package/dist/modules/rate-limit/middleware.d.ts.map +1 -0
  34. package/dist/modules/rate-limit/middleware.js +127 -0
  35. package/dist/modules/rate-limit/middleware.js.map +1 -0
  36. package/dist/modules/rate-limit/repository.d.ts +32 -0
  37. package/dist/modules/rate-limit/repository.d.ts.map +1 -0
  38. package/dist/modules/rate-limit/repository.js +48 -0
  39. package/dist/modules/rate-limit/repository.js.map +1 -0
  40. package/dist/modules/rate-limit/schema.d.ts +125 -0
  41. package/dist/modules/rate-limit/schema.d.ts.map +1 -0
  42. package/dist/modules/rate-limit/schema.js +29 -0
  43. package/dist/modules/rate-limit/schema.js.map +1 -0
  44. package/dist/modules/workspace/repository.d.ts +14 -1
  45. package/dist/modules/workspace/repository.d.ts.map +1 -1
  46. package/dist/modules/workspace/repository.js.map +1 -1
  47. package/dist/modules/workspace/service.d.ts +15 -5
  48. package/dist/modules/workspace/service.d.ts.map +1 -1
  49. package/dist/modules/workspace/service.js.map +1 -1
  50. package/dist/runtime/registry.d.ts +14 -4
  51. package/dist/runtime/registry.d.ts.map +1 -1
  52. package/dist/runtime/registry.js.map +1 -1
  53. package/dist/schema/index.d.ts +1 -0
  54. package/dist/schema/index.d.ts.map +1 -1
  55. package/dist/schema/index.js +1 -0
  56. package/dist/schema/index.js.map +1 -1
  57. package/dist/testing/app.d.ts +5 -0
  58. package/dist/testing/app.d.ts.map +1 -1
  59. package/dist/testing/app.js +14 -0
  60. package/dist/testing/app.js.map +1 -1
  61. package/dist/webBundle.d.ts +37 -0
  62. package/dist/webBundle.d.ts.map +1 -0
  63. package/dist/webBundle.js +75 -0
  64. package/dist/webBundle.js.map +1 -0
  65. package/migrations/0016_misty_mentor.sql +11 -0
  66. package/migrations/meta/0016_snapshot.json +5154 -0
  67. package/migrations/meta/_journal.json +7 -0
  68. package/package.json +3 -2
  69. package/src/app.ts +46 -0
  70. package/src/index.ts +4 -1
  71. package/src/lib/config.ts +13 -0
  72. package/src/lib/errors.ts +4 -0
  73. package/src/lib/ids.ts +5 -0
  74. package/src/lib/rateLimit.ts +45 -0
  75. package/src/lib/securityHeaders.ts +44 -0
  76. package/src/modules/import-export/drafts.ts +15 -2
  77. package/src/modules/rate-limit/middleware.ts +177 -0
  78. package/src/modules/rate-limit/repository.ts +64 -0
  79. package/src/modules/rate-limit/schema.ts +34 -0
  80. package/src/modules/workspace/repository.ts +12 -1
  81. package/src/modules/workspace/service.ts +15 -5
  82. package/src/runtime/registry.ts +14 -4
  83. package/src/schema/index.ts +1 -0
  84. package/src/testing/app.ts +21 -0
  85. package/src/webBundle.ts +110 -0
@@ -43,22 +43,32 @@ export interface ModuleRouter {
43
43
  readonly router: Hono
44
44
  }
45
45
 
46
+ /**
47
+ * What `registerModules` needs.
48
+ *
49
+ * Every optional field spells out `| undefined`, which is not noise. Under
50
+ * `exactOptionalPropertyTypes` an omitted key and a key holding `undefined` are
51
+ * different types, and an assembly building these options has the second:
52
+ * `readModuleConfigFile` returns `undefined` when no override file is
53
+ * configured. Without it, core hands a caller a value its own runtime refuses.
54
+ * None of the four fields tells the two cases apart.
55
+ */
46
56
  export interface ModuleRuntimeOptions {
47
57
  readonly modules: readonly KelpieModule[]
48
58
  /** Raw variables. Each module validates the slice it needs via `context.config`. */
49
59
  readonly environment: Environment
50
60
  readonly logger: Logger
51
61
  /** Injected so a test can watch what core modules subscribe to. Defaults to a fresh bus. */
52
- readonly events?: EventBus
62
+ readonly events?: EventBus | undefined
53
63
  /** Injected so a test can grant or deny before core modules register. */
54
- readonly entitlements?: EntitlementRegistry
64
+ readonly entitlements?: EntitlementRegistry | undefined
55
65
  /** The database, transaction scope, and collaborators every module builds on. */
56
66
  readonly services: ModuleServices
57
67
  /**
58
68
  * The deploy-time module override (`lib/moduleConfig.ts`), parsed. A locked
59
69
  * module id wins over whatever a workspace's own settings say.
60
70
  */
61
- readonly moduleConfig?: Readonly<Record<string, boolean>>
71
+ readonly moduleConfig?: Readonly<Record<string, boolean>> | undefined
62
72
  /**
63
73
  * Resolves the caller of a REST request, the same way every route already
64
74
  * does (`modules/auth/credentials.ts`), so a non-structural module's router
@@ -70,7 +80,7 @@ export interface ModuleRuntimeOptions {
70
80
  * every test that does not exercise module toggling wants without having to
71
81
  * say so.
72
82
  */
73
- readonly resolveActor?: (context: Context) => Promise<Actor>
83
+ readonly resolveActor?: ((context: Context) => Promise<Actor>) | undefined
74
84
  }
75
85
 
76
86
  /** Contributions accumulate here, one mutable set per registration pass. */
@@ -28,3 +28,4 @@ export * from '../modules/import-export/schema.ts'
28
28
  export * from '../modules/agent-tasks/schema.ts'
29
29
  export * from '../modules/webhooks/schema.ts'
30
30
  export * from '../modules/integrations/schema.ts'
31
+ export * from '../modules/rate-limit/schema.ts'
@@ -6,6 +6,8 @@ import type { Actor } from '../lib/actor.ts'
6
6
  import type { Environment } from '../lib/config.ts'
7
7
  import type { DatabaseProbe } from '../lib/database.ts'
8
8
  import { createLogger } from '../lib/logger.ts'
9
+ import { rateLimitConfigFrom, rateLimitConfigSchema } from '../lib/rateLimit.ts'
10
+ import type { RateLimitConfig } from '../lib/rateLimit.ts'
9
11
  import type { KelpieModule } from '../runtime/module.ts'
10
12
  import type { ModuleContributions } from '../runtime/registry.ts'
11
13
  import type { EntitlementRegistry } from '../runtime/entitlements.ts'
@@ -14,6 +16,19 @@ import { TEST_ENVIRONMENT } from './environment.ts'
14
16
  import { createTestServices } from './services.ts'
15
17
  import type { TestServices } from './services.ts'
16
18
 
19
+ /** The same defaults `loadConfig` produces from an empty environment: one source of numbers for both. */
20
+ const DEFAULT_TEST_RATE_LIMIT: RateLimitConfig = rateLimitConfigFrom(rateLimitConfigSchema.parse({}))
21
+
22
+ /**
23
+ * A caller's IP, for tests. Real entry points resolve this from the socket
24
+ * (`apps/kelpie/src/server.ts`); a test using `app.request()` has no socket, so
25
+ * it reads `X-Forwarded-For` when a test sets one to simulate distinct
26
+ * callers, falling back to a fixed address for everything else.
27
+ */
28
+ function testClientIp(context: Context): string {
29
+ return context.req.header('X-Forwarded-For') ?? '203.0.113.1'
30
+ }
31
+
17
32
  /**
18
33
  * Assembles an app for tests. Unlike the real boot it defaults every dependency,
19
34
  * because a test that has to spell out an environment it does not care about
@@ -38,6 +53,10 @@ export interface TestAppOptions {
38
53
  * route behaves as it did before module toggling existed.
39
54
  */
40
55
  readonly resolveActor?: (context: Context) => Promise<Actor>
56
+ /** Defaults to the same numbers `loadConfig` would, from an empty environment. */
57
+ readonly rateLimit?: RateLimitConfig
58
+ /** Defaults to reading `X-Forwarded-For`, so a test can simulate distinct callers. */
59
+ readonly resolveClientIp?: (context: Context) => string
41
60
  }
42
61
 
43
62
  export interface TestApp {
@@ -73,6 +92,8 @@ export async function createTestApp(options: TestAppOptions = {}): Promise<TestA
73
92
  credentials: { db: services.db, now: services.now },
74
93
  generateRequestId: options.generateRequestId ?? (() => 'req-test'),
75
94
  createId: services.createId,
95
+ rateLimit: options.rateLimit ?? DEFAULT_TEST_RATE_LIMIT,
96
+ resolveClientIp: options.resolveClientIp ?? testClientIp,
76
97
  })
77
98
 
78
99
  return { app, contributions, logLines, services }
@@ -0,0 +1,110 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { join, resolve } from 'node:path'
3
+
4
+ import { serveStatic } from '@hono/node-server/serve-static'
5
+ import type { Hono, MiddlewareHandler } from 'hono'
6
+
7
+ import type { AppBindings } from './app.ts'
8
+ import { MCP_ROUTE_PREFIX } from './modules/mcp/index.ts'
9
+
10
+ /**
11
+ * Serves a built web bundle from the same origin as the API.
12
+ *
13
+ * `createApp` answers data and nothing else. In development the Vite dev server
14
+ * builds the pages and proxies `/v1` through to the API, so one address serves
15
+ * both. That proxy is doing two jobs: rebuilding on edit, which is development
16
+ * only, and putting the pages and the API on one origin, which is permanent.
17
+ * Nothing did the second job in production, so a deployed assembly answered API
18
+ * calls and served no pages at all.
19
+ *
20
+ * An assembly opts in by calling this after `createApp`, which is where the
21
+ * three assemblies (`apps/kelpie`, a scaffolded project, and `kelpie-cloud`)
22
+ * would otherwise each grow their own copy of the fallback rule below.
23
+ */
24
+
25
+ export interface WebBundleOptions {
26
+ /** Directory holding the built `index.html` and its assets. */
27
+ readonly directory: string
28
+ }
29
+
30
+ /** The bundle directory does not hold a build. Thrown at boot, never per request. */
31
+ export class WebBundleError extends Error {
32
+ constructor(message: string) {
33
+ super(message)
34
+ this.name = 'WebBundleError'
35
+ }
36
+ }
37
+
38
+ /**
39
+ * The prefixes `createApp` answers on.
40
+ *
41
+ * `/v1/public` needs no entry of its own: it sits under `/v1`. `MCP_ROUTE_PREFIX`
42
+ * is imported rather than written out, so moving the endpoint moves this with it.
43
+ */
44
+ const API_PREFIXES: readonly string[] = ['/v1', MCP_ROUTE_PREFIX, '/healthz']
45
+
46
+ function isApiRequest(path: string): boolean {
47
+ return API_PREFIXES.some((prefix) => path === prefix || path.startsWith(`${prefix}/`))
48
+ }
49
+
50
+ /**
51
+ * Restricts a handler to requests for the web app.
52
+ *
53
+ * The API exclusion is the point of the whole file. `app.notFound` renders
54
+ * `api.md`'s JSON 404, and it only fires when no route matched, so a bare
55
+ * catch-all registered after `createApp` would answer `GET /v1/typo` with the
56
+ * app shell and a 200. A client asking for data would get a web page and no
57
+ * indication it had misspelled anything.
58
+ *
59
+ * Methods are filtered for the same reason: `POST /v1/typo` is a wrong endpoint,
60
+ * not a page request, and only a `GET` or a `HEAD` can sensibly be answered with
61
+ * a document.
62
+ */
63
+ function webRequestsOnly(handler: MiddlewareHandler): MiddlewareHandler {
64
+ return async (context, next) => {
65
+ const isDocumentRequest = context.req.method === 'GET' || context.req.method === 'HEAD'
66
+
67
+ if (!isDocumentRequest || isApiRequest(context.req.path)) {
68
+ return next()
69
+ }
70
+
71
+ return handler(context, next)
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Mounts the bundle on an app built by `createApp`.
77
+ *
78
+ * Call it after `createApp` and before serving. Hono composes a request's
79
+ * handlers in registration order and stops at the first one that answers, so
80
+ * mounting last leaves every API route matching ahead of these two.
81
+ *
82
+ * @throws WebBundleError if the directory holds no `index.html`. A deployment
83
+ * whose build did not run should stop at boot rather than serve an API with
84
+ * invisible pages, which is the failure this function exists to remove.
85
+ */
86
+ export function serveWebBundle(app: Hono<AppBindings>, options: WebBundleOptions): void {
87
+ const directory = resolve(options.directory)
88
+ const indexHtml = join(directory, 'index.html')
89
+
90
+ if (!existsSync(indexHtml)) {
91
+ throw new WebBundleError(
92
+ `No index.html in ${directory}. Point WEB_BUNDLE_DIR at a built web bundle, or run the web build first.`,
93
+ )
94
+ }
95
+
96
+ // Two registrations rather than one: `serveStatic` calls `next()` when it
97
+ // finds no file, which is exactly the signal the fallback needs. A request
98
+ // for a real asset is answered by the first and never reaches the second.
99
+ app.use('*', webRequestsOnly(serveStatic({ root: directory })))
100
+
101
+ // The single-page fallback. The app decides what to draw from the address, so
102
+ // a deep link to `/people/per_01J…` has to return the same `index.html` even
103
+ // though no file sits at that path.
104
+ //
105
+ // A missing asset gets the shell too, which is what every static SPA server
106
+ // does by default. Narrowing this by `Accept` would turn a stale asset
107
+ // reference into a clean 404, and would also turn `curl /people/per_01J…`
108
+ // into one, so it is left alone.
109
+ app.use('*', webRequestsOnly(serveStatic({ path: indexHtml })))
110
+ }