@kelpie/server 0.6.0 → 0.8.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 (266) hide show
  1. package/dist/app.d.ts +13 -0
  2. package/dist/app.d.ts.map +1 -1
  3. package/dist/app.js +9 -0
  4. package/dist/app.js.map +1 -1
  5. package/dist/boot.d.ts +42 -0
  6. package/dist/boot.d.ts.map +1 -0
  7. package/dist/boot.js +57 -0
  8. package/dist/boot.js.map +1 -0
  9. package/dist/index.d.ts +3 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +5 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/lib/config.d.ts +7 -0
  14. package/dist/lib/config.d.ts.map +1 -1
  15. package/dist/lib/config.js +2 -0
  16. package/dist/lib/config.js.map +1 -1
  17. package/dist/lib/consumerEmailDomains.d.ts +2 -0
  18. package/dist/lib/consumerEmailDomains.d.ts.map +1 -0
  19. package/dist/lib/consumerEmailDomains.js +62 -0
  20. package/dist/lib/consumerEmailDomains.js.map +1 -0
  21. package/dist/lib/email.d.ts +7 -0
  22. package/dist/lib/email.d.ts.map +1 -1
  23. package/dist/lib/email.js.map +1 -1
  24. package/dist/lib/emailContent.d.ts +33 -0
  25. package/dist/lib/emailContent.d.ts.map +1 -0
  26. package/dist/lib/emailContent.js +54 -0
  27. package/dist/lib/emailContent.js.map +1 -0
  28. package/dist/lib/emailDomainAutoLink.d.ts +27 -0
  29. package/dist/lib/emailDomainAutoLink.d.ts.map +1 -0
  30. package/dist/lib/emailDomainAutoLink.js +119 -0
  31. package/dist/lib/emailDomainAutoLink.js.map +1 -0
  32. package/dist/lib/ids.d.ts +2 -0
  33. package/dist/lib/ids.d.ts.map +1 -1
  34. package/dist/lib/ids.js +2 -0
  35. package/dist/lib/ids.js.map +1 -1
  36. package/dist/lib/kelpieConfigFile.d.ts +6 -0
  37. package/dist/lib/kelpieConfigFile.d.ts.map +1 -1
  38. package/dist/lib/kelpieConfigFile.js +1 -0
  39. package/dist/lib/kelpieConfigFile.js.map +1 -1
  40. package/dist/modules/attachedRecords.d.ts.map +1 -1
  41. package/dist/modules/attachedRecords.js +4 -2
  42. package/dist/modules/attachedRecords.js.map +1 -1
  43. package/dist/modules/auth/preferences.d.ts +11 -1
  44. package/dist/modules/auth/preferences.d.ts.map +1 -1
  45. package/dist/modules/auth/preferences.js +11 -0
  46. package/dist/modules/auth/preferences.js.map +1 -1
  47. package/dist/modules/auth/routes.d.ts.map +1 -1
  48. package/dist/modules/auth/routes.js +18 -0
  49. package/dist/modules/auth/routes.js.map +1 -1
  50. package/dist/modules/auth/schema.d.ts +20 -0
  51. package/dist/modules/auth/schema.d.ts.map +1 -1
  52. package/dist/modules/auth/schema.js +11 -1
  53. package/dist/modules/auth/schema.js.map +1 -1
  54. package/dist/modules/auth/service.d.ts.map +1 -1
  55. package/dist/modules/auth/service.js +27 -3
  56. package/dist/modules/auth/service.js.map +1 -1
  57. package/dist/modules/companies/repository.d.ts +2 -0
  58. package/dist/modules/companies/repository.d.ts.map +1 -1
  59. package/dist/modules/companies/repository.js +1 -0
  60. package/dist/modules/companies/repository.js.map +1 -1
  61. package/dist/modules/companies/routes.d.ts +2 -0
  62. package/dist/modules/companies/routes.d.ts.map +1 -1
  63. package/dist/modules/companies/routes.js +11 -0
  64. package/dist/modules/companies/routes.js.map +1 -1
  65. package/dist/modules/companies/schema.d.ts +17 -0
  66. package/dist/modules/companies/schema.d.ts.map +1 -1
  67. package/dist/modules/companies/schema.js +5 -1
  68. package/dist/modules/companies/schema.js.map +1 -1
  69. package/dist/modules/companies/service.d.ts +1 -0
  70. package/dist/modules/companies/service.d.ts.map +1 -1
  71. package/dist/modules/companies/service.js +22 -0
  72. package/dist/modules/companies/service.js.map +1 -1
  73. package/dist/modules/core.d.ts.map +1 -1
  74. package/dist/modules/core.js +11 -0
  75. package/dist/modules/core.js.map +1 -1
  76. package/dist/modules/email-domain-linker/index.d.ts +12 -0
  77. package/dist/modules/email-domain-linker/index.d.ts.map +1 -0
  78. package/dist/modules/email-domain-linker/index.js +34 -0
  79. package/dist/modules/email-domain-linker/index.js.map +1 -0
  80. package/dist/modules/email-domain-linker/routes.d.ts +14 -0
  81. package/dist/modules/email-domain-linker/routes.d.ts.map +1 -0
  82. package/dist/modules/email-domain-linker/routes.js +15 -0
  83. package/dist/modules/email-domain-linker/routes.js.map +1 -0
  84. package/dist/modules/email-domain-linker/service.d.ts +37 -0
  85. package/dist/modules/email-domain-linker/service.d.ts.map +1 -0
  86. package/dist/modules/email-domain-linker/service.js +63 -0
  87. package/dist/modules/email-domain-linker/service.js.map +1 -0
  88. package/dist/modules/eventCatalogs.d.ts +36 -0
  89. package/dist/modules/eventCatalogs.d.ts.map +1 -0
  90. package/dist/modules/eventCatalogs.js +36 -0
  91. package/dist/modules/eventCatalogs.js.map +1 -0
  92. package/dist/modules/import-export/drafts.d.ts +9 -0
  93. package/dist/modules/import-export/drafts.d.ts.map +1 -1
  94. package/dist/modules/import-export/drafts.js +25 -0
  95. package/dist/modules/import-export/drafts.js.map +1 -1
  96. package/dist/modules/import-export/exportRows.d.ts +9 -0
  97. package/dist/modules/import-export/exportRows.d.ts.map +1 -1
  98. package/dist/modules/import-export/exportRows.js +10 -1
  99. package/dist/modules/import-export/exportRows.js.map +1 -1
  100. package/dist/modules/import-export/plan.d.ts +23 -1
  101. package/dist/modules/import-export/plan.d.ts.map +1 -1
  102. package/dist/modules/import-export/plan.js +62 -3
  103. package/dist/modules/import-export/plan.js.map +1 -1
  104. package/dist/modules/import-export/presets.d.ts.map +1 -1
  105. package/dist/modules/import-export/presets.js +25 -0
  106. package/dist/modules/import-export/presets.js.map +1 -1
  107. package/dist/modules/import-export/repository.d.ts +17 -0
  108. package/dist/modules/import-export/repository.d.ts.map +1 -1
  109. package/dist/modules/import-export/repository.js +21 -1
  110. package/dist/modules/import-export/repository.js.map +1 -1
  111. package/dist/modules/import-export/routes.d.ts.map +1 -1
  112. package/dist/modules/import-export/routes.js +5 -1
  113. package/dist/modules/import-export/routes.js.map +1 -1
  114. package/dist/modules/import-export/schema.d.ts +55 -0
  115. package/dist/modules/import-export/schema.d.ts.map +1 -1
  116. package/dist/modules/import-export/schema.js +13 -1
  117. package/dist/modules/import-export/schema.js.map +1 -1
  118. package/dist/modules/import-export/service.d.ts +4 -1
  119. package/dist/modules/import-export/service.d.ts.map +1 -1
  120. package/dist/modules/import-export/service.js +51 -3
  121. package/dist/modules/import-export/service.js.map +1 -1
  122. package/dist/modules/import-export/tools.d.ts.map +1 -1
  123. package/dist/modules/import-export/tools.js +7 -1
  124. package/dist/modules/import-export/tools.js.map +1 -1
  125. package/dist/modules/import-export/writes.d.ts +16 -0
  126. package/dist/modules/import-export/writes.d.ts.map +1 -1
  127. package/dist/modules/import-export/writes.js +77 -2
  128. package/dist/modules/import-export/writes.js.map +1 -1
  129. package/dist/modules/lists/events.d.ts +42 -0
  130. package/dist/modules/lists/events.d.ts.map +1 -0
  131. package/dist/modules/lists/events.js +16 -0
  132. package/dist/modules/lists/events.js.map +1 -0
  133. package/dist/modules/lists/index.d.ts +10 -0
  134. package/dist/modules/lists/index.d.ts.map +1 -0
  135. package/dist/modules/lists/index.js +35 -0
  136. package/dist/modules/lists/index.js.map +1 -0
  137. package/dist/modules/lists/repository.d.ts +69 -0
  138. package/dist/modules/lists/repository.d.ts.map +1 -0
  139. package/dist/modules/lists/repository.js +165 -0
  140. package/dist/modules/lists/repository.js.map +1 -0
  141. package/dist/modules/lists/routes.d.ts +51 -0
  142. package/dist/modules/lists/routes.d.ts.map +1 -0
  143. package/dist/modules/lists/routes.js +160 -0
  144. package/dist/modules/lists/routes.js.map +1 -0
  145. package/dist/modules/lists/schema.d.ts +264 -0
  146. package/dist/modules/lists/schema.d.ts.map +1 -0
  147. package/dist/modules/lists/schema.js +73 -0
  148. package/dist/modules/lists/schema.js.map +1 -0
  149. package/dist/modules/lists/service.d.ts +70 -0
  150. package/dist/modules/lists/service.d.ts.map +1 -0
  151. package/dist/modules/lists/service.js +214 -0
  152. package/dist/modules/lists/service.js.map +1 -0
  153. package/dist/modules/lists/tools.d.ts +4 -0
  154. package/dist/modules/lists/tools.d.ts.map +1 -0
  155. package/dist/modules/lists/tools.js +86 -0
  156. package/dist/modules/lists/tools.js.map +1 -0
  157. package/dist/modules/people/repository.d.ts +11 -0
  158. package/dist/modules/people/repository.d.ts.map +1 -1
  159. package/dist/modules/people/repository.js +16 -0
  160. package/dist/modules/people/repository.js.map +1 -1
  161. package/dist/modules/people/service.d.ts.map +1 -1
  162. package/dist/modules/people/service.js +18 -0
  163. package/dist/modules/people/service.js.map +1 -1
  164. package/dist/modules/positions/routes.d.ts.map +1 -1
  165. package/dist/modules/positions/routes.js +4 -2
  166. package/dist/modules/positions/routes.js.map +1 -1
  167. package/dist/modules/sample-data/fixture.d.ts +139 -0
  168. package/dist/modules/sample-data/fixture.d.ts.map +1 -0
  169. package/dist/modules/sample-data/fixture.js +461 -0
  170. package/dist/modules/sample-data/fixture.js.map +1 -0
  171. package/dist/modules/sample-data/index.d.ts +11 -0
  172. package/dist/modules/sample-data/index.d.ts.map +1 -0
  173. package/dist/modules/sample-data/index.js +43 -0
  174. package/dist/modules/sample-data/index.js.map +1 -0
  175. package/dist/modules/sample-data/routes.d.ts +15 -0
  176. package/dist/modules/sample-data/routes.d.ts.map +1 -0
  177. package/dist/modules/sample-data/routes.js +24 -0
  178. package/dist/modules/sample-data/routes.js.map +1 -0
  179. package/dist/modules/sample-data/service.d.ts +38 -0
  180. package/dist/modules/sample-data/service.d.ts.map +1 -0
  181. package/dist/modules/sample-data/service.js +412 -0
  182. package/dist/modules/sample-data/service.js.map +1 -0
  183. package/dist/modules/sample-data/tools.d.ts +4 -0
  184. package/dist/modules/sample-data/tools.d.ts.map +1 -0
  185. package/dist/modules/sample-data/tools.js +22 -0
  186. package/dist/modules/sample-data/tools.js.map +1 -0
  187. package/dist/modules/smtp-email/index.d.ts +1 -0
  188. package/dist/modules/smtp-email/index.d.ts.map +1 -1
  189. package/dist/modules/smtp-email/index.js +1 -0
  190. package/dist/modules/smtp-email/index.js.map +1 -1
  191. package/dist/modules/workspace/service.d.ts.map +1 -1
  192. package/dist/modules/workspace/service.js +10 -1
  193. package/dist/modules/workspace/service.js.map +1 -1
  194. package/dist/schema/index.d.ts +1 -0
  195. package/dist/schema/index.d.ts.map +1 -1
  196. package/dist/schema/index.js +1 -0
  197. package/dist/schema/index.js.map +1 -1
  198. package/dist/testing/app.d.ts +5 -1
  199. package/dist/testing/app.d.ts.map +1 -1
  200. package/dist/testing/app.js +2 -0
  201. package/dist/testing/app.js.map +1 -1
  202. package/migrations/0019_sharp_red_skull.sql +4 -0
  203. package/migrations/0020_early_nemesis.sql +2 -0
  204. package/migrations/0021_certain_maggott.sql +30 -0
  205. package/migrations/0022_stormy_manta.sql +1 -0
  206. package/migrations/0023_funny_maddog.sql +1 -0
  207. package/migrations/meta/0019_snapshot.json +5138 -0
  208. package/migrations/meta/0020_snapshot.json +5138 -0
  209. package/migrations/meta/0021_snapshot.json +5389 -0
  210. package/migrations/meta/0022_snapshot.json +5396 -0
  211. package/migrations/meta/0023_snapshot.json +5403 -0
  212. package/migrations/meta/_journal.json +35 -0
  213. package/package.json +3 -2
  214. package/src/app.ts +26 -0
  215. package/src/boot.ts +89 -0
  216. package/src/index.ts +8 -0
  217. package/src/lib/config.ts +9 -0
  218. package/src/lib/consumerEmailDomains.ts +62 -0
  219. package/src/lib/email.ts +7 -0
  220. package/src/lib/emailContent.ts +91 -0
  221. package/src/lib/emailDomainAutoLink.ts +165 -0
  222. package/src/lib/ids.ts +2 -0
  223. package/src/lib/kelpieConfigFile.ts +8 -0
  224. package/src/modules/attachedRecords.ts +9 -2
  225. package/src/modules/auth/preferences.ts +13 -1
  226. package/src/modules/auth/routes.ts +22 -0
  227. package/src/modules/auth/schema.ts +12 -2
  228. package/src/modules/auth/service.ts +37 -3
  229. package/src/modules/companies/repository.ts +3 -0
  230. package/src/modules/companies/routes.ts +13 -0
  231. package/src/modules/companies/schema.ts +5 -1
  232. package/src/modules/companies/service.ts +25 -0
  233. package/src/modules/core.ts +11 -0
  234. package/src/modules/email-domain-linker/index.ts +40 -0
  235. package/src/modules/email-domain-linker/routes.ts +40 -0
  236. package/src/modules/email-domain-linker/service.ts +122 -0
  237. package/src/modules/eventCatalogs.ts +36 -0
  238. package/src/modules/import-export/drafts.ts +27 -0
  239. package/src/modules/import-export/exportRows.ts +10 -1
  240. package/src/modules/import-export/plan.ts +107 -5
  241. package/src/modules/import-export/presets.ts +25 -0
  242. package/src/modules/import-export/repository.ts +35 -1
  243. package/src/modules/import-export/routes.ts +5 -0
  244. package/src/modules/import-export/schema.ts +13 -0
  245. package/src/modules/import-export/service.ts +70 -4
  246. package/src/modules/import-export/tools.ts +9 -0
  247. package/src/modules/import-export/writes.ts +116 -3
  248. package/src/modules/lists/events.ts +41 -0
  249. package/src/modules/lists/index.ts +41 -0
  250. package/src/modules/lists/repository.ts +313 -0
  251. package/src/modules/lists/routes.ts +247 -0
  252. package/src/modules/lists/schema.ts +85 -0
  253. package/src/modules/lists/service.ts +404 -0
  254. package/src/modules/lists/tools.ts +128 -0
  255. package/src/modules/people/repository.ts +26 -0
  256. package/src/modules/people/service.ts +20 -0
  257. package/src/modules/positions/routes.ts +4 -2
  258. package/src/modules/sample-data/fixture.ts +627 -0
  259. package/src/modules/sample-data/index.ts +50 -0
  260. package/src/modules/sample-data/routes.ts +49 -0
  261. package/src/modules/sample-data/service.ts +590 -0
  262. package/src/modules/sample-data/tools.ts +32 -0
  263. package/src/modules/smtp-email/index.ts +8 -1
  264. package/src/modules/workspace/service.ts +13 -1
  265. package/src/schema/index.ts +1 -0
  266. package/src/testing/app.ts +7 -1
@@ -134,6 +134,41 @@
134
134
  "when": 1786656430307,
135
135
  "tag": "0018_hard_mystique",
136
136
  "breakpoints": true
137
+ },
138
+ {
139
+ "idx": 19,
140
+ "version": "7",
141
+ "when": 1787446086593,
142
+ "tag": "0019_sharp_red_skull",
143
+ "breakpoints": true
144
+ },
145
+ {
146
+ "idx": 20,
147
+ "version": "7",
148
+ "when": 1787447911296,
149
+ "tag": "0020_early_nemesis",
150
+ "breakpoints": true
151
+ },
152
+ {
153
+ "idx": 21,
154
+ "version": "7",
155
+ "when": 1787527717966,
156
+ "tag": "0021_certain_maggott",
157
+ "breakpoints": true
158
+ },
159
+ {
160
+ "idx": 22,
161
+ "version": "7",
162
+ "when": 1787626585359,
163
+ "tag": "0022_stormy_manta",
164
+ "breakpoints": true
165
+ },
166
+ {
167
+ "idx": 23,
168
+ "version": "7",
169
+ "when": 1787651539093,
170
+ "tag": "0023_funny_maddog",
171
+ "breakpoints": true
137
172
  }
138
173
  ]
139
174
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kelpie/server",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "The Kelpie service as a library: module runtime, core CRM modules, REST API, MCP surface, and the shared migration pipeline.",
5
5
  "keywords": [
6
6
  "kelpie",
@@ -52,11 +52,12 @@
52
52
  },
53
53
  "dependencies": {
54
54
  "@hono/node-server": "^2.0.12",
55
- "@kelpie/schemas": "^0.6.0",
55
+ "@kelpie/schemas": "^0.8.0",
56
56
  "@node-rs/argon2": "^2.0.2",
57
57
  "drizzle-orm": "^0.45.2",
58
58
  "emittery": "^2.0.0",
59
59
  "hono": "^4.12.32",
60
+ "mailgen": "^2.0.36",
60
61
  "nodemailer": "^9.0.5",
61
62
  "postgres": "^3.4.9",
62
63
  "ulid": "^3.0.2",
package/src/app.ts CHANGED
@@ -2,6 +2,7 @@ import { Hono } from 'hono'
2
2
  import type { Context } from 'hono'
3
3
  import { cors } from 'hono/cors'
4
4
 
5
+ import type { RuntimeMode } from './lib/config.ts'
5
6
  import type { DatabaseProbe } from './lib/database.ts'
6
7
  import { AppError, internalErrorBody, toErrorBody } from './lib/errors.ts'
7
8
  import { PUBLIC_ROUTE_PREFIX } from './lib/http.ts'
@@ -51,6 +52,18 @@ export interface AppDependencies {
51
52
  * control (`testing/app.ts`).
52
53
  */
53
54
  readonly resolveClientIp: (context: Context) => string
55
+ /**
56
+ * Reported through `GET /v1/public/config` so the browser knows which
57
+ * runtime it is talking to. Defaults to `'production'` so a caller that has
58
+ * not thought about it fails safe: no banner shows.
59
+ */
60
+ readonly runtimeMode?: RuntimeMode
61
+ /**
62
+ * Reported through `GET /v1/public/config` so a non-production UI can name
63
+ * the site it is on. Undefined is fine and means "this deployment did not
64
+ * name itself".
65
+ */
66
+ readonly siteName?: string | undefined
54
67
  }
55
68
 
56
69
  /** Per-request values the middleware chain sets and handlers read. */
@@ -161,6 +174,19 @@ export function createApp(dependencies: AppDependencies): Hono<AppBindings> {
161
174
  app.route(PUBLIC_ROUTE_PREFIX, router)
162
175
  }
163
176
 
177
+ // Public deployment metadata the browser reads once at boot. Sits under the
178
+ // public prefix so the CORS and rate-limit layers above cover it, and takes
179
+ // no credentials for the same reason /healthz does not: the pages that read
180
+ // it include the sign-in page, which runs before any session exists. The
181
+ // runtime mode is already visible in error messages, and the site name
182
+ // exists to be visible; nothing here is sensitive.
183
+ const runtimeMode: RuntimeMode = dependencies.runtimeMode ?? 'production'
184
+ const siteName = dependencies.siteName ?? null
185
+
186
+ app.get(`${PUBLIC_ROUTE_PREFIX}/config`, (context) =>
187
+ context.json({ runtime_mode: runtimeMode, site_name: siteName }, 200),
188
+ )
189
+
164
190
  for (const { router } of dependencies.contributions.routers) {
165
191
  app.route('/v1', router)
166
192
  }
package/src/boot.ts ADDED
@@ -0,0 +1,89 @@
1
+ import type { Environment, KelpieConfig } from './lib/config.ts'
2
+ import { connectDatabase } from './lib/database.ts'
3
+ import type { DatabaseConnection } from './lib/database.ts'
4
+ import { createIdFactory } from './lib/ids.ts'
5
+ import type { IdFactory } from './lib/ids.ts'
6
+ import { resolveKelpieConfig } from './lib/kelpieConfigFile.ts'
7
+ import type { KelpieConfigInput } from './lib/kelpieConfigFile.ts'
8
+ import { createLogger, createTransportForDestination } from './lib/logger.ts'
9
+ import type { Logger } from './lib/logger.ts'
10
+ import { readModuleConfigFile } from './lib/moduleConfig.ts'
11
+ import { resolveActorFrom } from './modules/auth/credentials.ts'
12
+ import type { CredentialDependencies } from './modules/auth/credentials.ts'
13
+ import { createEventBus } from './runtime/events.ts'
14
+ import { registerModules } from './runtime/registry.ts'
15
+ import type { ModuleContributions } from './runtime/registry.ts'
16
+ import { createTransactionScope } from './runtime/transaction.ts'
17
+
18
+ /**
19
+ * A resolved assembly: config parsed, database connected, modules registered.
20
+ *
21
+ * This is the common prelude every entry point needs before it does its own
22
+ * work. `server.ts` goes on to build the app and serve; `migrate.ts` goes on to
23
+ * apply migrations and exit. Both start here, so the wiring lives in one place
24
+ * rather than drifting between two copies.
25
+ *
26
+ * The database is connected but not opened: postgres.js connects lazily, and
27
+ * registration issues no query, so a caller that only needs the contributions
28
+ * pays for no connection. The caller owns `database.close()`.
29
+ */
30
+ export interface AssemblyBoot {
31
+ readonly config: KelpieConfig
32
+ readonly logger: Logger
33
+ readonly database: DatabaseConnection
34
+ readonly createId: IdFactory
35
+ readonly credentials: CredentialDependencies
36
+ readonly contributions: ModuleContributions
37
+ }
38
+
39
+ /**
40
+ * Resolves an assembly's config, connects the database, and runs the module
41
+ * registration pass.
42
+ *
43
+ * Registration touches no database. It only collects what the modules
44
+ * contribute, migrations directories included, which is why an entry point can
45
+ * call this and then decide whether to serve, migrate, or both.
46
+ *
47
+ * @throws ConfigurationError when a required variable is missing or malformed.
48
+ * @throws ModuleConfigFileError when the module override file cannot be read.
49
+ * @throws ModuleBootError when the module list is invalid or a module fails to
50
+ * register. The caller maps these to a fix hint and an exit code.
51
+ */
52
+ export async function bootAssembly(
53
+ kelpieConfig: KelpieConfigInput,
54
+ environment: Environment,
55
+ ): Promise<AssemblyBoot> {
56
+ const config = resolveKelpieConfig(kelpieConfig, environment)
57
+ const logger = createLogger({
58
+ level: config.logging.level,
59
+ transports: config.logging.destinations.map(createTransportForDestination),
60
+ })
61
+ const database = connectDatabase(config.databaseUrl, logger)
62
+ const events = createEventBus(logger)
63
+ const createId = createIdFactory()
64
+ const credentials: CredentialDependencies = { db: database.db, now: () => new Date() }
65
+ const moduleConfig = readModuleConfigFile(config.moduleConfigPath)
66
+ const contributions = await registerModules({
67
+ modules: kelpieConfig.modules,
68
+ environment: config.env,
69
+ logger,
70
+ events,
71
+ moduleConfig,
72
+ resolveActor: (context) => resolveActorFrom(credentials, context),
73
+ services: {
74
+ db: database.db,
75
+ transaction: createTransactionScope({ db: database.db, bus: events, logger, createId }),
76
+ createId,
77
+ now: () => new Date(),
78
+ appBaseUrl: config.appBaseUrl,
79
+ secretEncryption: config.secretEncryption,
80
+ },
81
+ // `provider` picks a named sender from the runtime's registry. `'log'` is
82
+ // built in; `'smtp'` is registered by the built-in `smtp-email` core
83
+ // module; other names come from third-party provider modules listed in
84
+ // `kelpie.config.ts`. `from` is the address on every outgoing message.
85
+ email: { provider: config.email.EMAIL_PROVIDER, from: config.email.EMAIL_FROM },
86
+ })
87
+
88
+ return { config, logger, database, createId, credentials, contributions }
89
+ }
package/src/index.ts CHANGED
@@ -1,6 +1,14 @@
1
+ // Carries each module's `KelpieEventMap` augmentation into the emitted
2
+ // `index.d.ts` so consumers installed from npm get a typed `event.data`. See
3
+ // `modules/eventCatalogs.ts` for why the entry point has to reference them.
4
+ import './modules/eventCatalogs.ts'
5
+
1
6
  export { createApp } from './app.ts'
2
7
  export type { AppDependencies, AppBindings } from './app.ts'
3
8
 
9
+ export { bootAssembly } from './boot.ts'
10
+ export type { AssemblyBoot } from './boot.ts'
11
+
4
12
  export { WebBundleError, serveWebBundle } from './webBundle.ts'
5
13
  export type { WebBundleOptions } from './webBundle.ts'
6
14
 
package/src/lib/config.ts CHANGED
@@ -39,6 +39,13 @@ export interface KelpieConfig {
39
39
  readonly runtimeMode: RuntimeMode
40
40
  readonly port: number
41
41
  readonly databaseUrl: string
42
+ /**
43
+ * Human-readable name of this deployment ("dev", "demo", "cloud", or
44
+ * whatever the self-hoster picks). Undefined is normal. Exposed to the
45
+ * browser through `GET /v1/public/config` so a non-production UI can name
46
+ * the site it is on. Ignored when `runtimeMode` is `production`.
47
+ */
48
+ readonly siteName: string | undefined
42
49
  readonly logging: LoggingConfig
43
50
  /**
44
51
  * The provider name the module runtime looks up in its registry, and the
@@ -119,6 +126,7 @@ const environmentSchema = z.object({
119
126
  .refine(isPostgresUrl, { message: 'must be a postgres:// or postgresql:// connection string' }),
120
127
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']),
121
128
  KELPIE_MODULE_CONFIG_PATH: z.string().min(1).optional(),
129
+ KELPIE_SITE_NAME: z.string().min(1).optional(),
122
130
  WEB_BUNDLE_DIR: z.string().min(1).optional(),
123
131
  TRUSTED_PROXY_HOP_COUNT: z.coerce.number().int().nonnegative().default(0),
124
132
  ...rateLimitConfigSchema.shape,
@@ -156,6 +164,7 @@ export function loadConfig(environment: Environment): KelpieConfig {
156
164
  },
157
165
  email: emailResult.data,
158
166
  moduleConfigPath: environmentResult.data.KELPIE_MODULE_CONFIG_PATH,
167
+ siteName: environmentResult.data.KELPIE_SITE_NAME,
159
168
  webBundleDirectory: environmentResult.data.WEB_BUNDLE_DIR,
160
169
  rateLimit: rateLimitConfigFrom(environmentResult.data),
161
170
  trustedProxyHopCount: environmentResult.data.TRUSTED_PROXY_HOP_COUNT,
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Consumer email hosts, checked case-insensitively.
3
+ *
4
+ * The email-domain listener refuses to auto-link a Person to a Company on any
5
+ * domain in this list. A consumer address belongs to no one company, and a
6
+ * Company row entered by mistake against, say, `gmail.com` would otherwise
7
+ * auto-attach every consumer address after.
8
+ *
9
+ * A starter list of the common providers. Add locale variants and less common
10
+ * hosts as the need arrives.
11
+ */
12
+ const CONSUMER_EMAIL_DOMAINS: ReadonlySet<string> = new Set([
13
+ 'gmail.com',
14
+ 'googlemail.com',
15
+ 'yahoo.com',
16
+ 'yahoo.co.uk',
17
+ 'yahoo.co.jp',
18
+ 'yahoo.fr',
19
+ 'yahoo.de',
20
+ 'yahoo.ca',
21
+ 'yahoo.com.au',
22
+ 'ymail.com',
23
+ 'rocketmail.com',
24
+ 'hotmail.com',
25
+ 'hotmail.co.uk',
26
+ 'hotmail.fr',
27
+ 'hotmail.de',
28
+ 'hotmail.it',
29
+ 'hotmail.es',
30
+ 'outlook.com',
31
+ 'outlook.co.uk',
32
+ 'live.com',
33
+ 'live.co.uk',
34
+ 'msn.com',
35
+ 'aol.com',
36
+ 'aim.com',
37
+ 'icloud.com',
38
+ 'me.com',
39
+ 'mac.com',
40
+ 'protonmail.com',
41
+ 'proton.me',
42
+ 'pm.me',
43
+ 'gmx.com',
44
+ 'gmx.net',
45
+ 'gmx.de',
46
+ 'mail.com',
47
+ 'fastmail.com',
48
+ 'fastmail.fm',
49
+ 'zoho.com',
50
+ 'yandex.com',
51
+ 'yandex.ru',
52
+ 'tutanota.com',
53
+ 'tuta.io',
54
+ 'qq.com',
55
+ '163.com',
56
+ '126.com',
57
+ 'naver.com',
58
+ ])
59
+
60
+ export function isConsumerEmailDomain(domain: string): boolean {
61
+ return CONSUMER_EMAIL_DOMAINS.has(domain.trim().toLowerCase())
62
+ }
package/src/lib/email.ts CHANGED
@@ -21,7 +21,14 @@ import type { Logger } from './logger.ts'
21
21
  export interface EmailMessage {
22
22
  readonly to: string
23
23
  readonly subject: string
24
+ /** The plaintext part. Always present: text-only clients and logs read it. */
24
25
  readonly body: string
26
+ /**
27
+ * The HTML alternative, rendered by `lib/emailContent.ts`. Optional so
28
+ * provider modules written against the older port keep compiling and a
29
+ * plain-text message stays expressible.
30
+ */
31
+ readonly html?: string
25
32
  }
26
33
 
27
34
  export interface EmailSender {
@@ -0,0 +1,91 @@
1
+ import Mailgen from 'mailgen'
2
+
3
+ /**
4
+ * Renders transactional mail bodies: a plaintext part and an HTML part from
5
+ * one description of the message.
6
+ *
7
+ * Email clients need table-based, inline-styled HTML; mailgen owns that
8
+ * problem so Kelpie does not. The plaintext part is built here by hand rather
9
+ * than by mailgen's plaintext generator, because that generator pads lines
10
+ * with trailing whitespace and the plaintext body is the part tests and
11
+ * text-only clients read token links out of. The wording of the two parts
12
+ * comes from the same fields, so they cannot drift.
13
+ */
14
+
15
+ /** A link the message asks the reader to follow, rendered as a button in HTML. */
16
+ export interface EmailAction {
17
+ readonly instructions: string
18
+ readonly buttonText: string
19
+ readonly link: string
20
+ }
21
+
22
+ export interface EmailContent {
23
+ readonly intro?: string
24
+ readonly action?: EmailAction
25
+ readonly outro?: string
26
+ }
27
+
28
+ export interface RenderedEmail {
29
+ readonly text: string
30
+ readonly html: string
31
+ }
32
+
33
+ /**
34
+ * Fixed so rendering is deterministic. Mailgen's default footer stamps the
35
+ * current year, which the sender has no claim to: the workspace, not Kelpie,
36
+ * owns the relationship with the recipient.
37
+ */
38
+ const FOOTER_LINE = 'Sent by Kelpie.'
39
+
40
+ /**
41
+ * Renders one transactional message. `appBaseUrl` becomes the link on the
42
+ * product name in the header and footer, so every part of the mail points at
43
+ * the deployment that sent it.
44
+ */
45
+ export function renderEmail(content: EmailContent, appBaseUrl: string): RenderedEmail {
46
+ const generator = new Mailgen({
47
+ theme: 'default',
48
+ product: {
49
+ name: 'Kelpie',
50
+ link: appBaseUrl,
51
+ copyright: FOOTER_LINE,
52
+ },
53
+ })
54
+
55
+ const html: string = generator.generate({
56
+ body: {
57
+ greeting: false,
58
+ signature: false,
59
+ ...(content.intro === undefined ? {} : { intro: content.intro }),
60
+ ...(content.action === undefined
61
+ ? {}
62
+ : {
63
+ action: {
64
+ instructions: content.action.instructions,
65
+ button: {
66
+ text: content.action.buttonText,
67
+ link: content.action.link,
68
+ fallback: true,
69
+ },
70
+ },
71
+ }),
72
+ ...(content.outro === undefined ? {} : { outro: content.outro }),
73
+ },
74
+ })
75
+
76
+ const paragraphs: string[] = []
77
+
78
+ if (content.intro !== undefined) {
79
+ paragraphs.push(content.intro)
80
+ }
81
+
82
+ if (content.action !== undefined) {
83
+ paragraphs.push(`${content.action.instructions}\n\n${content.action.link}`)
84
+ }
85
+
86
+ if (content.outro !== undefined) {
87
+ paragraphs.push(content.outro)
88
+ }
89
+
90
+ return { text: paragraphs.join('\n\n'), html }
91
+ }
@@ -0,0 +1,165 @@
1
+ import { UNIQUE_VIOLATION, postgresErrorCode } from './database.ts'
2
+ import type { IdFactory } from './ids.ts'
3
+ import { isConsumerEmailDomain } from './consumerEmailDomains.ts'
4
+ import type { BufferedEvents, Transaction } from '../runtime/transaction.ts'
5
+ import type { ActivityRecorder, SystemActor } from '../modules/activities/recorder.ts'
6
+ import { describeLink } from '../modules/activities/wording.ts'
7
+ import * as companyRepository from '../modules/companies/repository.ts'
8
+ import type { CompanyRecord } from '../modules/companies/repository.ts'
9
+ import * as peopleRepository from '../modules/people/repository.ts'
10
+ import type { PersonRecord } from '../modules/people/repository.ts'
11
+ import '../modules/positions/events.ts'
12
+ import * as positionRepository from '../modules/positions/repository.ts'
13
+
14
+ /**
15
+ * Auto-link a Person to a workspace Company whose domain matches the person's
16
+ * email. Runs inside the caller's transaction, so the new Position is present
17
+ * in the response the API returns for the same request.
18
+ *
19
+ * Skips: no email, consumer email host (`isConsumerEmailDomain`), no Company
20
+ * on that domain in the workspace, or a Position already linking the two. A
21
+ * unique-key race with a concurrent linker is absorbed.
22
+ *
23
+ * The created Position carries an empty title. `positions.title` is `NOT NULL`,
24
+ * and empty reads honestly as "the domain matched; no title yet".
25
+ *
26
+ * Split from the people service so the layering (people below positions) is
27
+ * carried by imports only: this helper depends on both repositories, and the
28
+ * people service depends on this helper. The `positions` module still owns the
29
+ * event catalogue for the emitted event.
30
+ */
31
+
32
+ /** What the timeline calls this side effect. */
33
+ const LINKER_ACTOR: SystemActor = { kind: 'system', label: 'Email domain match' }
34
+
35
+ export interface AutoLinkDependencies {
36
+ readonly createId: IdFactory
37
+ readonly now: () => Date
38
+ readonly recordActivity: ActivityRecorder
39
+ }
40
+
41
+ /**
42
+ * Considers `person` for a matching Company in `workspaceId` and inserts a
43
+ * titleless Position if none links them yet.
44
+ *
45
+ * @returns The new Position's id, or `undefined` if nothing was written.
46
+ */
47
+ export async function autoLinkPersonByEmailDomain(
48
+ tx: Transaction,
49
+ events: BufferedEvents,
50
+ workspaceId: string,
51
+ person: PersonRecord,
52
+ dependencies: AutoLinkDependencies,
53
+ ): Promise<string | undefined> {
54
+ if (person.email === null) {
55
+ return undefined
56
+ }
57
+
58
+ const at = person.email.indexOf('@')
59
+ if (at < 0) {
60
+ return undefined
61
+ }
62
+
63
+ const domain = person.email.slice(at + 1)
64
+ if (domain.length === 0 || isConsumerEmailDomain(domain)) {
65
+ return undefined
66
+ }
67
+
68
+ const company = await companyRepository.findCompanyByDomain(tx, workspaceId, domain)
69
+ if (company === undefined) {
70
+ return undefined
71
+ }
72
+
73
+ const held = await positionRepository.listPositionsAt(tx, workspaceId, person.id, company.id)
74
+ if (held.length > 0) {
75
+ return undefined
76
+ }
77
+
78
+ return insertStubPosition(tx, events, workspaceId, person, company, dependencies)
79
+ }
80
+
81
+ /**
82
+ * Sweeps every Person in `workspaceId` whose email domain matches `company`'s
83
+ * domain and stubs a Position where none exists yet. Called when a Company is
84
+ * created or its domain is set.
85
+ *
86
+ * @returns The ids of every Position this call inserted.
87
+ */
88
+ export async function autoLinkCompanyByDomain(
89
+ tx: Transaction,
90
+ events: BufferedEvents,
91
+ workspaceId: string,
92
+ company: CompanyRecord,
93
+ dependencies: AutoLinkDependencies,
94
+ ): Promise<readonly string[]> {
95
+ if (company.domain === null || company.domain.length === 0) {
96
+ return []
97
+ }
98
+
99
+ if (isConsumerEmailDomain(company.domain)) {
100
+ return []
101
+ }
102
+
103
+ const matches = await peopleRepository.findPeopleByEmailDomain(tx, workspaceId, company.domain)
104
+ const created: string[] = []
105
+
106
+ for (const person of matches) {
107
+ const held = await positionRepository.listPositionsAt(tx, workspaceId, person.id, company.id)
108
+ if (held.length > 0) {
109
+ continue
110
+ }
111
+
112
+ const id = await insertStubPosition(tx, events, workspaceId, person, company, dependencies)
113
+ if (id !== undefined) {
114
+ created.push(id)
115
+ }
116
+ }
117
+
118
+ return created
119
+ }
120
+
121
+ /**
122
+ * The write half. Wraps the unique-constraint race the two entry points share:
123
+ * a concurrent linker that beat us to `(personId, companyId, '')` is treated
124
+ * as success (the link is there, either way).
125
+ */
126
+ async function insertStubPosition(
127
+ tx: Transaction,
128
+ events: BufferedEvents,
129
+ workspaceId: string,
130
+ person: PersonRecord,
131
+ company: CompanyRecord,
132
+ dependencies: AutoLinkDependencies,
133
+ ): Promise<string | undefined> {
134
+ try {
135
+ const created = await positionRepository.insertPosition(tx, {
136
+ id: dependencies.createId('position'),
137
+ workspaceId,
138
+ personId: person.id,
139
+ companyId: company.id,
140
+ title: '',
141
+ })
142
+
143
+ await dependencies.recordActivity(tx, workspaceId, LINKER_ACTOR, {
144
+ targetType: 'person',
145
+ targetId: person.id,
146
+ kind: 'linked',
147
+ ...describeLink('company', company.name),
148
+ })
149
+ await dependencies.recordActivity(tx, workspaceId, LINKER_ACTOR, {
150
+ targetType: 'company',
151
+ targetId: company.id,
152
+ kind: 'linked',
153
+ ...describeLink('person', person.name),
154
+ })
155
+
156
+ events.emit('positions.position.created', { type: 'position', id: created.id }, {})
157
+
158
+ return created.id
159
+ } catch (error: unknown) {
160
+ if (postgresErrorCode(error) === UNIQUE_VIOLATION) {
161
+ return undefined
162
+ }
163
+ throw error
164
+ }
165
+ }
package/src/lib/ids.ts CHANGED
@@ -28,6 +28,8 @@ export const idPrefixes = {
28
28
  decision: 'dec',
29
29
  note: 'note',
30
30
  activity: 'act',
31
+ list: 'list',
32
+ listMember: 'lmem',
31
33
  handbookPage: 'hb',
32
34
  form: 'form',
33
35
  formField: 'ff',
@@ -27,6 +27,12 @@ export interface KelpieConfigInput {
27
27
  readonly webBundleDirectory?: ConfigValue<string | undefined>
28
28
  /** Optional; unset in development, set to the deploy-time module override file. */
29
29
  readonly moduleConfigPath?: ConfigValue<string | undefined>
30
+ /**
31
+ * Human-readable name of this deployment ("dev", "demo", "cloud"). Optional.
32
+ * Read by the browser through `GET /v1/public/config` so a non-production UI
33
+ * can name the site it is on. Ignored when `runtimeMode` is `production`.
34
+ */
35
+ readonly siteName?: ConfigValue<string | undefined>
30
36
  /** Optional; defaults to 0 (no proxy in front). */
31
37
  readonly trustedProxyHopCount?: ConfigValue<number>
32
38
  /**
@@ -173,6 +179,7 @@ export function resolveKelpieConfig(input: KelpieConfigInput, environment: Envir
173
179
  },
174
180
  email,
175
181
  moduleConfigPath: resolved.moduleConfigPath,
182
+ siteName: resolved.siteName,
176
183
  webBundleDirectory: resolved.webBundleDirectory,
177
184
  rateLimit,
178
185
  trustedProxyHopCount: resolved.trustedProxyHopCount ?? 0,
@@ -208,6 +215,7 @@ interface ResolvedInput {
208
215
  }
209
216
  readonly webBundleDirectory?: string | undefined
210
217
  readonly moduleConfigPath?: string | undefined
218
+ readonly siteName?: string | undefined
211
219
  readonly trustedProxyHopCount?: number
212
220
  readonly appBaseUrl?: string
213
221
  readonly secretEncryption?: ResolvedSecretEncryption
@@ -6,6 +6,7 @@ import type { Queryable } from '../runtime/transaction.ts'
6
6
  import * as activityRepository from './activities/repository.ts'
7
7
  import * as decisionRepository from './decisions/repository.ts'
8
8
  import { candidates } from './hiring/schema.ts'
9
+ import * as listRepository from './lists/repository.ts'
9
10
  import * as noteRepository from './notes/repository.ts'
10
11
  import * as planRepository from './plans/repository.ts'
11
12
 
@@ -37,7 +38,7 @@ export async function deleteRecordsAttachedTo(
37
38
  targetType: AttachableTargetType,
38
39
  targetId: string,
39
40
  ): Promise<number> {
40
- // Sequential, not concurrent: a transaction is one connection, and four
41
+ // Sequential, not concurrent: a transaction is one connection, and five
41
42
  // statements racing down it is not something to rely on for a cheap delete.
42
43
  const notes = await noteRepository.deleteForTarget(db, workspaceId, targetType, targetId)
43
44
  const activities = await activityRepository.deleteForTarget(db, workspaceId, targetType, targetId)
@@ -45,8 +46,14 @@ export async function deleteRecordsAttachedTo(
45
46
  const plans = PLAN_TARGET_TYPES.has(targetType)
46
47
  ? await planRepository.deleteForTarget(db, workspaceId, targetType, targetId)
47
48
  : 0
49
+ const memberships = await listRepository.deleteMembershipsForTarget(
50
+ db,
51
+ workspaceId,
52
+ targetType,
53
+ targetId,
54
+ )
48
55
 
49
- return notes + activities + decisions + plans
56
+ return notes + activities + decisions + plans + memberships
50
57
  }
51
58
 
52
59
  /**