@cosmicdrift/kumiko-framework 0.1.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 (388) hide show
  1. package/README.md +159 -0
  2. package/package.json +91 -0
  3. package/src/__tests__/anonymous-access.integration.ts +325 -0
  4. package/src/__tests__/error-contract.integration.ts +435 -0
  5. package/src/__tests__/field-access.integration.ts +269 -0
  6. package/src/__tests__/full-stack.integration.ts +914 -0
  7. package/src/__tests__/ownership.integration.ts +449 -0
  8. package/src/__tests__/reference-data.integration.ts +198 -0
  9. package/src/__tests__/transition-guard.integration.ts +340 -0
  10. package/src/api/__tests__/api.test.ts +337 -0
  11. package/src/api/__tests__/auth-middleware-transport.test.ts +80 -0
  12. package/src/api/__tests__/auth-routes-cookie.test.ts +179 -0
  13. package/src/api/__tests__/batch.integration.ts +404 -0
  14. package/src/api/__tests__/body-limit.test.ts +88 -0
  15. package/src/api/__tests__/csrf-middleware.test.ts +97 -0
  16. package/src/api/__tests__/dispatcher-live.integration.ts +216 -0
  17. package/src/api/__tests__/metrics-endpoint.test.ts +126 -0
  18. package/src/api/__tests__/nested-write.integration.ts +213 -0
  19. package/src/api/__tests__/readiness.test.ts +76 -0
  20. package/src/api/__tests__/request-id-middleware.test.ts +72 -0
  21. package/src/api/__tests__/sse-broker.test.ts +58 -0
  22. package/src/api/__tests__/sse-route.test.ts +112 -0
  23. package/src/api/anonymous-cookie.ts +60 -0
  24. package/src/api/api-constants.ts +64 -0
  25. package/src/api/auth-middleware.ts +418 -0
  26. package/src/api/auth-routes.ts +982 -0
  27. package/src/api/csrf-middleware.ts +77 -0
  28. package/src/api/index.ts +31 -0
  29. package/src/api/jwt.ts +66 -0
  30. package/src/api/observability-middleware.ts +89 -0
  31. package/src/api/readiness.ts +132 -0
  32. package/src/api/request-context.ts +49 -0
  33. package/src/api/request-id-middleware.ts +50 -0
  34. package/src/api/route-registrars.ts +195 -0
  35. package/src/api/routes.ts +135 -0
  36. package/src/api/server.ts +640 -0
  37. package/src/api/sse-broker.ts +71 -0
  38. package/src/api/sse-route.ts +62 -0
  39. package/src/api/tokens.ts +16 -0
  40. package/src/db/__tests__/apply-entity-event-tenant.integration.ts +159 -0
  41. package/src/db/__tests__/compound-types.test.ts +114 -0
  42. package/src/db/__tests__/connection-options.test.ts +68 -0
  43. package/src/db/__tests__/cursor.test.ts +41 -0
  44. package/src/db/__tests__/db-helpers.test.ts +369 -0
  45. package/src/db/__tests__/dialect-instant.test.ts +50 -0
  46. package/src/db/__tests__/drizzle-helpers.integration.ts +186 -0
  47. package/src/db/__tests__/drizzle-table-types.test.ts +162 -0
  48. package/src/db/__tests__/encryption.test.ts +39 -0
  49. package/src/db/__tests__/event-store-executor-list.integration.ts +313 -0
  50. package/src/db/__tests__/event-store-executor.integration.ts +235 -0
  51. package/src/db/__tests__/implicit-projection-equivalence.integration.ts +304 -0
  52. package/src/db/__tests__/located-timestamp.test.ts +184 -0
  53. package/src/db/__tests__/money.test.ts +199 -0
  54. package/src/db/__tests__/multi-row-insert.integration.ts +76 -0
  55. package/src/db/__tests__/parse-auto-verb.test.ts +70 -0
  56. package/src/db/__tests__/required-not-null-migration-safety.integration.ts +105 -0
  57. package/src/db/__tests__/row-helpers.test.ts +59 -0
  58. package/src/db/__tests__/schema-migration.integration.ts +273 -0
  59. package/src/db/__tests__/table-builder-indexes.test.ts +153 -0
  60. package/src/db/__tests__/table-builder-required.test.ts +216 -0
  61. package/src/db/__tests__/tenant-db.integration.ts +606 -0
  62. package/src/db/__tests__/unique-violation-mapping.integration.ts +166 -0
  63. package/src/db/apply-entity-event.ts +188 -0
  64. package/src/db/assert-exists-in.ts +59 -0
  65. package/src/db/compound-types.ts +47 -0
  66. package/src/db/connection.ts +104 -0
  67. package/src/db/cursor.ts +83 -0
  68. package/src/db/dialect.ts +109 -0
  69. package/src/db/eagerload.ts +174 -0
  70. package/src/db/encryption.ts +39 -0
  71. package/src/db/event-store-executor.ts +906 -0
  72. package/src/db/index.ts +55 -0
  73. package/src/db/located-timestamp.ts +114 -0
  74. package/src/db/money.ts +120 -0
  75. package/src/db/pg-error.ts +46 -0
  76. package/src/db/reference-data.ts +77 -0
  77. package/src/db/row-helpers.ts +53 -0
  78. package/src/db/schema-inspection.ts +25 -0
  79. package/src/db/table-builder.ts +475 -0
  80. package/src/db/tenant-db.ts +434 -0
  81. package/src/engine/__tests__/auth-claims-registrar.test.ts +74 -0
  82. package/src/engine/__tests__/boot-validator-located-timestamps.test.ts +108 -0
  83. package/src/engine/__tests__/boot-validator.test.ts +1865 -0
  84. package/src/engine/__tests__/build-app-schema.test.ts +154 -0
  85. package/src/engine/__tests__/claim-keys.test.ts +274 -0
  86. package/src/engine/__tests__/config-helpers.test.ts +236 -0
  87. package/src/engine/__tests__/effective-features.test.ts +86 -0
  88. package/src/engine/__tests__/engine.test.ts +1461 -0
  89. package/src/engine/__tests__/entity-handlers.test.ts +274 -0
  90. package/src/engine/__tests__/event-helpers.test.ts +68 -0
  91. package/src/engine/__tests__/extends-registrar.test.ts +159 -0
  92. package/src/engine/__tests__/factories-long-text.test.ts +84 -0
  93. package/src/engine/__tests__/factories-time.test.ts +158 -0
  94. package/src/engine/__tests__/field-predicates.test.ts +48 -0
  95. package/src/engine/__tests__/hook-phases.test.ts +132 -0
  96. package/src/engine/__tests__/identifiers.test.ts +35 -0
  97. package/src/engine/__tests__/lifecycle-hooks.test.ts +237 -0
  98. package/src/engine/__tests__/nav.test.ts +267 -0
  99. package/src/engine/__tests__/ownership.test.ts +421 -0
  100. package/src/engine/__tests__/parse-ref-target.test.ts +43 -0
  101. package/src/engine/__tests__/projection-helpers.test.ts +62 -0
  102. package/src/engine/__tests__/projection.test.ts +191 -0
  103. package/src/engine/__tests__/qualified-name.test.ts +264 -0
  104. package/src/engine/__tests__/resolve-config-or-param.test.ts +315 -0
  105. package/src/engine/__tests__/run-in.test.ts +38 -0
  106. package/src/engine/__tests__/schema-builder.test.ts +380 -0
  107. package/src/engine/__tests__/screen.test.ts +408 -0
  108. package/src/engine/__tests__/state-machine.test.ts +148 -0
  109. package/src/engine/__tests__/system-user.test.ts +57 -0
  110. package/src/engine/__tests__/validation-hooks.test.ts +71 -0
  111. package/src/engine/access.ts +23 -0
  112. package/src/engine/boot-validator.ts +1528 -0
  113. package/src/engine/build-app-schema.ts +125 -0
  114. package/src/engine/config-helpers.ts +115 -0
  115. package/src/engine/constants.ts +85 -0
  116. package/src/engine/create-app.ts +98 -0
  117. package/src/engine/define-feature.ts +702 -0
  118. package/src/engine/define-handler.ts +78 -0
  119. package/src/engine/define-roles.ts +19 -0
  120. package/src/engine/effective-features.ts +87 -0
  121. package/src/engine/entity-handlers.ts +364 -0
  122. package/src/engine/event-helpers.ts +73 -0
  123. package/src/engine/factories.ts +328 -0
  124. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +416 -0
  125. package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +197 -0
  126. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +128 -0
  127. package/src/engine/feature-ast/__tests__/parse.test.ts +888 -0
  128. package/src/engine/feature-ast/__tests__/patch.test.ts +360 -0
  129. package/src/engine/feature-ast/__tests__/patcher.test.ts +469 -0
  130. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +287 -0
  131. package/src/engine/feature-ast/extractors.ts +2562 -0
  132. package/src/engine/feature-ast/index.ts +105 -0
  133. package/src/engine/feature-ast/parse.ts +369 -0
  134. package/src/engine/feature-ast/patch.ts +525 -0
  135. package/src/engine/feature-ast/patcher.ts +518 -0
  136. package/src/engine/feature-ast/patterns.ts +434 -0
  137. package/src/engine/feature-ast/render.ts +602 -0
  138. package/src/engine/feature-ast/source-location.ts +45 -0
  139. package/src/engine/field-access.ts +120 -0
  140. package/src/engine/index.ts +254 -0
  141. package/src/engine/ownership.ts +337 -0
  142. package/src/engine/parse-ref-target.ts +22 -0
  143. package/src/engine/pattern-library/__tests__/library.test.ts +351 -0
  144. package/src/engine/pattern-library/index.ts +24 -0
  145. package/src/engine/pattern-library/library.ts +1117 -0
  146. package/src/engine/pattern-library/types.ts +255 -0
  147. package/src/engine/projection-helpers.ts +85 -0
  148. package/src/engine/qualified-name.ts +122 -0
  149. package/src/engine/read-claim.ts +31 -0
  150. package/src/engine/registry.ts +1325 -0
  151. package/src/engine/resolve-config-or-param.ts +153 -0
  152. package/src/engine/run-in.ts +29 -0
  153. package/src/engine/schema-builder.ts +175 -0
  154. package/src/engine/screen-filter-ops.ts +51 -0
  155. package/src/engine/state-machine.ts +70 -0
  156. package/src/engine/system-user.ts +32 -0
  157. package/src/engine/types/config.ts +306 -0
  158. package/src/engine/types/event-type-map.ts +37 -0
  159. package/src/engine/types/feature.ts +574 -0
  160. package/src/engine/types/fields.ts +422 -0
  161. package/src/engine/types/handlers.ts +742 -0
  162. package/src/engine/types/hooks.ts +142 -0
  163. package/src/engine/types/http-route.ts +54 -0
  164. package/src/engine/types/identifiers.ts +47 -0
  165. package/src/engine/types/index.ts +208 -0
  166. package/src/engine/types/nav.ts +46 -0
  167. package/src/engine/types/projection.ts +132 -0
  168. package/src/engine/types/relations.ts +51 -0
  169. package/src/engine/types/screen.ts +452 -0
  170. package/src/engine/types/workspace.ts +42 -0
  171. package/src/engine/validation.ts +33 -0
  172. package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.ts +173 -0
  173. package/src/entrypoint/__tests__/split-deploy.integration.ts +297 -0
  174. package/src/entrypoint/index.ts +442 -0
  175. package/src/errors/__tests__/classes.test.ts +371 -0
  176. package/src/errors/__tests__/write-failures.test.ts +109 -0
  177. package/src/errors/classes.ts +249 -0
  178. package/src/errors/i18n/de.yaml +83 -0
  179. package/src/errors/i18n/en.yaml +80 -0
  180. package/src/errors/index.ts +41 -0
  181. package/src/errors/kumiko-error.ts +67 -0
  182. package/src/errors/reasons.ts +36 -0
  183. package/src/errors/serialize.ts +136 -0
  184. package/src/errors/transition-details.ts +30 -0
  185. package/src/errors/write-error-info.ts +123 -0
  186. package/src/errors/zod-bridge.ts +49 -0
  187. package/src/event-store/__tests__/admin-api.integration.ts +361 -0
  188. package/src/event-store/__tests__/event-store.integration.ts +584 -0
  189. package/src/event-store/__tests__/get-stream-version-perf.integration.ts +83 -0
  190. package/src/event-store/__tests__/perf.integration.ts +255 -0
  191. package/src/event-store/__tests__/snapshot.integration.ts +267 -0
  192. package/src/event-store/__tests__/upcaster-dead-letter.integration.ts +204 -0
  193. package/src/event-store/__tests__/upcaster.integration.ts +460 -0
  194. package/src/event-store/admin-api.ts +257 -0
  195. package/src/event-store/archive.ts +106 -0
  196. package/src/event-store/errors.ts +35 -0
  197. package/src/event-store/event-store.ts +405 -0
  198. package/src/event-store/events-schema.ts +90 -0
  199. package/src/event-store/index.ts +50 -0
  200. package/src/event-store/snapshot.ts +210 -0
  201. package/src/event-store/upcaster-dead-letter.ts +119 -0
  202. package/src/event-store/upcaster.ts +147 -0
  203. package/src/files/__tests__/content-disposition.test.ts +123 -0
  204. package/src/files/__tests__/file-field-column.integration.ts +103 -0
  205. package/src/files/__tests__/file-field-pipeline.integration.ts +211 -0
  206. package/src/files/__tests__/file-handle.test.ts +122 -0
  207. package/src/files/__tests__/files.integration.ts +830 -0
  208. package/src/files/__tests__/storage-tracking.integration.ts +153 -0
  209. package/src/files/content-disposition.ts +55 -0
  210. package/src/files/file-handle.ts +63 -0
  211. package/src/files/file-ref-table.ts +22 -0
  212. package/src/files/file-routes.ts +353 -0
  213. package/src/files/in-memory-provider.ts +62 -0
  214. package/src/files/index.ts +29 -0
  215. package/src/files/local-provider.ts +35 -0
  216. package/src/files/storage-tracking.ts +60 -0
  217. package/src/files/types.ts +118 -0
  218. package/src/i18n/__tests__/i18n.test.ts +72 -0
  219. package/src/i18n/index.ts +29 -0
  220. package/src/jobs/__tests__/job-event-trigger.integration.ts +172 -0
  221. package/src/jobs/__tests__/job-multi-trigger.integration.ts +144 -0
  222. package/src/jobs/__tests__/jobs.integration.ts +566 -0
  223. package/src/jobs/index.ts +2 -0
  224. package/src/jobs/job-runner.ts +574 -0
  225. package/src/lifecycle/__tests__/create-test-lifecycle.ts +19 -0
  226. package/src/lifecycle/__tests__/lifecycle-server.integration.ts +108 -0
  227. package/src/lifecycle/__tests__/lifecycle.test.ts +212 -0
  228. package/src/lifecycle/__tests__/signal-handlers.test.ts +106 -0
  229. package/src/lifecycle/index.ts +13 -0
  230. package/src/lifecycle/lifecycle.ts +160 -0
  231. package/src/lifecycle/signal-handlers.ts +62 -0
  232. package/src/logging/__tests__/pino-trace-bridge.test.ts +50 -0
  233. package/src/logging/index.ts +3 -0
  234. package/src/logging/pino-logger.ts +64 -0
  235. package/src/logging/types.ts +7 -0
  236. package/src/migrations/__tests__/compare-snapshots.test.ts +150 -0
  237. package/src/migrations/__tests__/detect-drift.integration.ts +320 -0
  238. package/src/migrations/__tests__/detect-projections-to-rebuild.integration.ts +134 -0
  239. package/src/migrations/__tests__/rebuild-marker.test.ts +79 -0
  240. package/src/migrations/index.ts +28 -0
  241. package/src/migrations/projection-detection.ts +149 -0
  242. package/src/migrations/rebuild-marker.ts +64 -0
  243. package/src/migrations/schema-drift.ts +395 -0
  244. package/src/observability/__tests__/console-provider.test.ts +67 -0
  245. package/src/observability/__tests__/metric-validator.test.ts +87 -0
  246. package/src/observability/__tests__/noop-provider.test.ts +82 -0
  247. package/src/observability/__tests__/observability.integration.ts +559 -0
  248. package/src/observability/__tests__/prometheus-meter.test.ts +144 -0
  249. package/src/observability/__tests__/recording-meter.test.ts +101 -0
  250. package/src/observability/__tests__/recording-tracer.test.ts +110 -0
  251. package/src/observability/__tests__/sensitive-filter.test.ts +98 -0
  252. package/src/observability/console-provider.ts +130 -0
  253. package/src/observability/context.ts +26 -0
  254. package/src/observability/fallback.ts +34 -0
  255. package/src/observability/ids.ts +25 -0
  256. package/src/observability/index.ts +79 -0
  257. package/src/observability/metric-validator.ts +86 -0
  258. package/src/observability/metrics-handle.ts +56 -0
  259. package/src/observability/noop-provider.ts +146 -0
  260. package/src/observability/prometheus-meter.ts +284 -0
  261. package/src/observability/recording-meter.ts +156 -0
  262. package/src/observability/recording-tracer.ts +198 -0
  263. package/src/observability/redis-wrapper.ts +132 -0
  264. package/src/observability/sensitive-filter.ts +108 -0
  265. package/src/observability/standard-metrics.ts +213 -0
  266. package/src/observability/types/index.ts +29 -0
  267. package/src/observability/types/metric.ts +56 -0
  268. package/src/observability/types/provider.ts +32 -0
  269. package/src/observability/types/span.ts +64 -0
  270. package/src/pipeline/__tests__/archive-stream.integration.ts +220 -0
  271. package/src/pipeline/__tests__/auth-claims-resolver.test.ts +279 -0
  272. package/src/pipeline/__tests__/cascade-handler.integration.ts +419 -0
  273. package/src/pipeline/__tests__/cascade-handler.test.ts +52 -0
  274. package/src/pipeline/__tests__/causation-chain.integration.ts +206 -0
  275. package/src/pipeline/__tests__/ctx-bridge.integration.ts +234 -0
  276. package/src/pipeline/__tests__/dispatcher.test.ts +379 -0
  277. package/src/pipeline/__tests__/distributed-lock.integration.ts +67 -0
  278. package/src/pipeline/__tests__/domain-events-projections.integration.ts +323 -0
  279. package/src/pipeline/__tests__/event-dedup.integration.ts +153 -0
  280. package/src/pipeline/__tests__/event-define-event-strict.integration.ts +202 -0
  281. package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.ts +220 -0
  282. package/src/pipeline/__tests__/event-dispatcher-multi-instance.integration.ts +423 -0
  283. package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.ts +123 -0
  284. package/src/pipeline/__tests__/event-dispatcher-recovery.integration.ts +202 -0
  285. package/src/pipeline/__tests__/event-dispatcher-second-audit.integration.ts +290 -0
  286. package/src/pipeline/__tests__/event-dispatcher-strict.test.ts +65 -0
  287. package/src/pipeline/__tests__/event-dispatcher.integration.ts +287 -0
  288. package/src/pipeline/__tests__/event-retention.integration.ts +239 -0
  289. package/src/pipeline/__tests__/fetch-for-writing.integration.ts +281 -0
  290. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +430 -0
  291. package/src/pipeline/__tests__/load-aggregate-query.integration.ts +266 -0
  292. package/src/pipeline/__tests__/msp-error-mode.integration.ts +149 -0
  293. package/src/pipeline/__tests__/msp-multi-hop.integration.ts +228 -0
  294. package/src/pipeline/__tests__/msp-rebuild.integration.ts +368 -0
  295. package/src/pipeline/__tests__/multi-stream-projection.integration.ts +341 -0
  296. package/src/pipeline/__tests__/perf-rebuild.integration.ts +147 -0
  297. package/src/pipeline/__tests__/projection-rebuild.integration.ts +551 -0
  298. package/src/pipeline/__tests__/query-projection.integration.ts +201 -0
  299. package/src/pipeline/__tests__/redis-pipeline.integration.ts +306 -0
  300. package/src/pipeline/append-event-core.ts +117 -0
  301. package/src/pipeline/auth-claims-resolver.ts +103 -0
  302. package/src/pipeline/cascade-handler.ts +113 -0
  303. package/src/pipeline/dispatcher.ts +1585 -0
  304. package/src/pipeline/distributed-lock.ts +37 -0
  305. package/src/pipeline/entity-cache.ts +113 -0
  306. package/src/pipeline/event-consumer-state.ts +108 -0
  307. package/src/pipeline/event-dedup.ts +23 -0
  308. package/src/pipeline/event-dispatcher.ts +1016 -0
  309. package/src/pipeline/event-retention.ts +154 -0
  310. package/src/pipeline/idempotency.ts +76 -0
  311. package/src/pipeline/index.ts +66 -0
  312. package/src/pipeline/lifecycle-pipeline.ts +409 -0
  313. package/src/pipeline/msp-rebuild.ts +242 -0
  314. package/src/pipeline/multi-stream-apply-context.ts +115 -0
  315. package/src/pipeline/projection-rebuild.ts +334 -0
  316. package/src/pipeline/projection-state.ts +72 -0
  317. package/src/pipeline/projections-runner.ts +56 -0
  318. package/src/pipeline/redis-keys.ts +11 -0
  319. package/src/pipeline/system-hooks.ts +190 -0
  320. package/src/random/__tests__/generate.test.ts +149 -0
  321. package/src/random/generate.ts +141 -0
  322. package/src/random/index.ts +8 -0
  323. package/src/random/words.ts +392 -0
  324. package/src/rate-limit/__tests__/dispatcher-l3.integration.ts +111 -0
  325. package/src/rate-limit/__tests__/middleware.integration.ts +189 -0
  326. package/src/rate-limit/__tests__/resolver.integration.ts +189 -0
  327. package/src/rate-limit/bucket.ts +36 -0
  328. package/src/rate-limit/index.ts +14 -0
  329. package/src/rate-limit/middleware.ts +152 -0
  330. package/src/rate-limit/resolver.ts +267 -0
  331. package/src/redis/__tests__/redis-options.test.ts +54 -0
  332. package/src/redis/index.ts +74 -0
  333. package/src/search/__tests__/meilisearch-adapter.integration.ts +236 -0
  334. package/src/search/__tests__/search-adapter.test.ts +256 -0
  335. package/src/search/in-memory-adapter.ts +123 -0
  336. package/src/search/index.ts +12 -0
  337. package/src/search/meilisearch-adapter.ts +106 -0
  338. package/src/search/types.ts +39 -0
  339. package/src/secrets/__tests__/dek-cache.test.ts +213 -0
  340. package/src/secrets/__tests__/env-master-key-provider.test.ts +119 -0
  341. package/src/secrets/__tests__/envelope.test.ts +74 -0
  342. package/src/secrets/__tests__/leak-guard.test.ts +92 -0
  343. package/src/secrets/__tests__/rotation.test.ts +149 -0
  344. package/src/secrets/dek-cache.ts +116 -0
  345. package/src/secrets/env-master-key-provider.ts +162 -0
  346. package/src/secrets/envelope.ts +55 -0
  347. package/src/secrets/index.ts +19 -0
  348. package/src/secrets/leak-guard.ts +87 -0
  349. package/src/secrets/rotation.ts +34 -0
  350. package/src/secrets/types.ts +107 -0
  351. package/src/stack/db.ts +104 -0
  352. package/src/stack/event-collector.ts +23 -0
  353. package/src/stack/index.ts +32 -0
  354. package/src/stack/redis.ts +44 -0
  355. package/src/stack/request-helper.ts +168 -0
  356. package/src/stack/table-helpers.ts +104 -0
  357. package/src/stack/test-stack.ts +357 -0
  358. package/src/stack/test-users.ts +37 -0
  359. package/src/testing/__tests__/e2e-generator.test.ts +230 -0
  360. package/src/testing/__tests__/ensure-entity-table.integration.ts +54 -0
  361. package/src/testing/access-assertions.ts +15 -0
  362. package/src/testing/assertions.ts +35 -0
  363. package/src/testing/e2e-generator.ts +465 -0
  364. package/src/testing/expect-error.ts +25 -0
  365. package/src/testing/handler-context.ts +125 -0
  366. package/src/testing/http-cookies.ts +52 -0
  367. package/src/testing/index.ts +41 -0
  368. package/src/testing/late-bound.ts +39 -0
  369. package/src/testing/mutable-master-key-provider.ts +31 -0
  370. package/src/testing/observability-recorder.ts +54 -0
  371. package/src/testing/shared-entities.ts +49 -0
  372. package/src/testing/utils.ts +1 -0
  373. package/src/testing/wait-for.ts +31 -0
  374. package/src/time/__tests__/polyfill.test.ts +73 -0
  375. package/src/time/__tests__/tz-context.test.ts +121 -0
  376. package/src/time/index.ts +21 -0
  377. package/src/time/polyfill.ts +70 -0
  378. package/src/time/tz-context.ts +107 -0
  379. package/src/ui-types/app-schema.ts +57 -0
  380. package/src/ui-types/index.ts +65 -0
  381. package/src/utils/__tests__/assert.test.ts +17 -0
  382. package/src/utils/__tests__/env-parse.test.ts +54 -0
  383. package/src/utils/assert.ts +18 -0
  384. package/src/utils/env-parse.ts +16 -0
  385. package/src/utils/ids.ts +16 -0
  386. package/src/utils/index.ts +5 -0
  387. package/src/utils/safe-json.ts +30 -0
  388. package/src/utils/serialization.ts +7 -0
@@ -0,0 +1,213 @@
1
+ import { eq } from "drizzle-orm";
2
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
3
+ import { z } from "zod";
4
+ import { createEventStoreExecutor } from "../../db/event-store-executor";
5
+ import { buildDrizzleTable } from "../../db/table-builder";
6
+ import { createEntity, createTextField, defineFeature } from "../../engine";
7
+ import { createEntityTable, setupTestStack, type TestStack, TestUsers } from "../../stack";
8
+
9
+ // Two entities in a 1:N relation. The relation is declared with
10
+ // `nestedWrite: true`, which opts the framework into expanding
11
+ // `{ tasks: [...] }` inside a project:create payload into a child-write
12
+ // per entry — all in the same TX as the parent.
13
+ const projectEntity = createEntity({
14
+ table: "nested_projects",
15
+ fields: { name: createTextField({ required: true }) },
16
+ });
17
+ const projectTable = buildDrizzleTable("project", projectEntity);
18
+
19
+ const taskEntity = createEntity({
20
+ table: "nested_tasks",
21
+ fields: {
22
+ projectId: createTextField({ required: true }),
23
+ title: createTextField({ required: true }),
24
+ },
25
+ });
26
+ const taskTable = buildDrizzleTable("task", taskEntity);
27
+
28
+ const nestedFeature = defineFeature("nested", (r) => {
29
+ r.entity("project", projectEntity);
30
+ r.entity("task", taskEntity);
31
+
32
+ r.relation("project", "tasks", {
33
+ type: "hasMany",
34
+ target: "task",
35
+ foreignKey: "projectId",
36
+ nestedWrite: true,
37
+ });
38
+
39
+ r.writeHandler(
40
+ "project:create",
41
+ z.object({ name: z.string().min(1) }),
42
+ async (event, ctx) => {
43
+ const crud = createEventStoreExecutor(projectTable, projectEntity, {
44
+ entityName: "project",
45
+ });
46
+ return crud.create(event.payload, event.user, ctx.db);
47
+ },
48
+ { access: { roles: ["Admin"] } },
49
+ );
50
+
51
+ r.writeHandler(
52
+ "task:create",
53
+ z.object({
54
+ projectId: z.string().uuid(),
55
+ title: z.string().min(1),
56
+ }),
57
+ async (event, ctx) => {
58
+ const crud = createEventStoreExecutor(taskTable, taskEntity, {
59
+ entityName: "task",
60
+ });
61
+ return crud.create(event.payload, event.user, ctx.db);
62
+ },
63
+ { access: { roles: ["Admin"] } },
64
+ );
65
+ });
66
+
67
+ let stack: TestStack;
68
+ const admin = TestUsers.admin;
69
+
70
+ beforeAll(async () => {
71
+ stack = await setupTestStack({ features: [nestedFeature] });
72
+ await createEntityTable(stack.db, projectEntity);
73
+ await createEntityTable(stack.db, taskEntity);
74
+ });
75
+
76
+ afterAll(async () => {
77
+ await stack.cleanup();
78
+ });
79
+
80
+ beforeEach(async () => {
81
+ await stack.db.delete(taskTable);
82
+ await stack.db.delete(projectTable);
83
+ });
84
+
85
+ describe("POST /api/write — nested-write (Welle M1)", () => {
86
+ test("project:create with nested tasks: parent + children atomic, response carries both", async () => {
87
+ const res = await stack.http.write(
88
+ "nested:write:project:create",
89
+ {
90
+ name: "P1",
91
+ tasks: [{ title: "t1" }, { title: "t2" }],
92
+ },
93
+ admin,
94
+ );
95
+
96
+ expect(res.status).toBe(200);
97
+ const body = await res.json();
98
+ expect(body.isSuccess).toBe(true);
99
+
100
+ // Handlers built on the CRUD executor wrap the row in a SaveContext
101
+ // (`{ kind: "save", data: <row>, ... }`). Nested children land on the
102
+ // inner `data` object, mirroring the nested shape the client sent up.
103
+ const parent = body.data.data;
104
+ expect(parent.id).toBeDefined();
105
+ expect(parent.name).toBe("P1");
106
+ expect(Array.isArray(parent.tasks)).toBe(true);
107
+ expect(parent.tasks).toHaveLength(2);
108
+ expect(parent.tasks[0].projectId).toBe(parent.id);
109
+ expect(parent.tasks[0].title).toBe("t1");
110
+ expect(parent.tasks[1].projectId).toBe(parent.id);
111
+ expect(parent.tasks[1].title).toBe("t2");
112
+
113
+ // DB reflects both writes.
114
+ const dbProjects = await stack.db.select().from(projectTable);
115
+ const dbTasks = await stack.db
116
+ .select()
117
+ .from(taskTable)
118
+ .where(eq(taskTable["projectId"], parent.id));
119
+ expect(dbProjects).toHaveLength(1);
120
+ expect(dbTasks).toHaveLength(2);
121
+ });
122
+
123
+ test("sub-write failure rolls back parent: neither project nor sibling tasks persist", async () => {
124
+ // Second task fails zod validation (empty title). The whole batch —
125
+ // parent + any prior sub — must roll back.
126
+ const res = await stack.http.write(
127
+ "nested:write:project:create",
128
+ {
129
+ name: "P2",
130
+ tasks: [{ title: "ok" }, { title: "" }],
131
+ },
132
+ admin,
133
+ );
134
+
135
+ expect(res.status).toBeGreaterThanOrEqual(400);
136
+ const body = await res.json();
137
+ expect(body.isSuccess).toBe(false);
138
+
139
+ // DB empty — prior sub-task and parent both rolled back.
140
+ const dbProjects = await stack.db.select().from(projectTable);
141
+ const dbTasks = await stack.db.select().from(taskTable);
142
+ expect(dbProjects).toHaveLength(0);
143
+ expect(dbTasks).toHaveLength(0);
144
+ });
145
+
146
+ test("sub-write validation error paths are prefixed with `<relKey>.<index>`", async () => {
147
+ // A zod failure on a nested item (empty title) must surface with a
148
+ // client-mappable path. The form-controller (Block 2) keys error
149
+ // messages off this path to highlight the right sub-line's field.
150
+ const res = await stack.http.write(
151
+ "nested:write:project:create",
152
+ {
153
+ name: "P4",
154
+ tasks: [{ title: "ok" }, { title: "" }],
155
+ },
156
+ admin,
157
+ );
158
+
159
+ expect(res.status).toBe(400);
160
+ const body = await res.json();
161
+ expect(body.error.code).toBe("validation_error");
162
+ const paths = (body.error.details.fields as Array<{ path: string }>).map((f) => f.path);
163
+ // At least one field issue points at tasks.1.title (index-1, empty title).
164
+ expect(paths.some((p) => p === "tasks.1.title")).toBe(true);
165
+ });
166
+
167
+ test("non-array value under a nested-write key is rejected (invalid_type)", async () => {
168
+ // Zod's default semantics would silently strip a malformed `tasks` value
169
+ // (e.g. a string or null) — the client would then see a 200 with no
170
+ // indication their data was dropped. Fail loud instead.
171
+ const res = await stack.http.write(
172
+ "nested:write:project:create",
173
+ { name: "P-shape", tasks: "not-an-array" },
174
+ admin,
175
+ );
176
+
177
+ expect(res.status).toBe(400);
178
+ const body = await res.json();
179
+ expect(body.error.code).toBe("validation_error");
180
+ expect(body.error.details.fields[0].path).toBe("tasks");
181
+ expect(body.error.details.fields[0].code).toBe("invalid_type");
182
+
183
+ // Parent did not persist — the pre-flight check runs before the parent
184
+ // write, so the TX never opened on a malformed nested key.
185
+ const dbProjects = await stack.db.select().from(projectTable);
186
+ expect(dbProjects).toHaveLength(0);
187
+ });
188
+
189
+ test("explicit foreign key in sub-payload is rejected", async () => {
190
+ // Security rail: a client trying to hang a nested task onto a different
191
+ // project id by smuggling `projectId` into the sub-payload must be
192
+ // refused up-front, not silently overwritten. The framework sets the
193
+ // fk itself from the parent's new id.
194
+ const res = await stack.http.write(
195
+ "nested:write:project:create",
196
+ {
197
+ name: "P3",
198
+ tasks: [{ title: "x", projectId: "00000000-0000-0000-0000-000000000001" }],
199
+ },
200
+ admin,
201
+ );
202
+
203
+ expect(res.status).toBe(400);
204
+ const body = await res.json();
205
+ expect(body.isSuccess).toBe(false);
206
+ expect(body.error.code).toBe("validation_error");
207
+ expect(body.error.details.fields[0].path).toMatch(/tasks\.0\.projectId/);
208
+
209
+ // DB empty.
210
+ const dbProjects = await stack.db.select().from(projectTable);
211
+ expect(dbProjects).toHaveLength(0);
212
+ });
213
+ });
@@ -0,0 +1,76 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { createReadinessProbe, type ReadinessCheck } from "../readiness";
3
+
4
+ function okCheck(name: string, delayMs = 0): ReadinessCheck {
5
+ return {
6
+ name,
7
+ run: async () => {
8
+ if (delayMs > 0) await new Promise((r) => setTimeout(r, delayMs));
9
+ },
10
+ };
11
+ }
12
+
13
+ function failCheck(name: string, message: string): ReadinessCheck {
14
+ return {
15
+ name,
16
+ run: async () => {
17
+ throw new Error(message);
18
+ },
19
+ };
20
+ }
21
+
22
+ function hangCheck(name: string): ReadinessCheck {
23
+ return {
24
+ name,
25
+ run: () => new Promise(() => {}),
26
+ };
27
+ }
28
+
29
+ describe("createReadinessProbe", () => {
30
+ test("all checks pass → ok=true", async () => {
31
+ const probe = createReadinessProbe([okCheck("a"), okCheck("b")]);
32
+ const result = await probe();
33
+ expect(result.ok).toBe(true);
34
+ expect(result.checks).toHaveLength(2);
35
+ expect(result.checks.every((c) => c.ok)).toBe(true);
36
+ });
37
+
38
+ test("one failing check drags the whole probe down", async () => {
39
+ const probe = createReadinessProbe([okCheck("db"), failCheck("redis", "ECONNREFUSED")]);
40
+ const result = await probe();
41
+ expect(result.ok).toBe(false);
42
+ expect(result.checks).toHaveLength(2);
43
+ const redis = result.checks.find((c) => c.name === "redis");
44
+ expect(redis?.ok).toBe(false);
45
+ expect(redis?.error).toContain("ECONNREFUSED");
46
+ });
47
+
48
+ test("hung check is aborted at timeoutMs and surfaces as failure", async () => {
49
+ const probe = createReadinessProbe([hangCheck("slow"), okCheck("fast")], { timeoutMs: 50 });
50
+ const start = performance.now();
51
+ const result = await probe();
52
+ const elapsed = performance.now() - start;
53
+ expect(elapsed).toBeLessThan(500); // far below the hang's infinity
54
+ expect(result.ok).toBe(false);
55
+ const slow = result.checks.find((c) => c.name === "slow");
56
+ expect(slow?.ok).toBe(false);
57
+ expect(slow?.error).toMatch(/timeout/i);
58
+ });
59
+
60
+ test("checks run in parallel — total latency ≈ slowest, not sum", async () => {
61
+ const probe = createReadinessProbe([okCheck("a", 80), okCheck("b", 80), okCheck("c", 80)]);
62
+ const start = performance.now();
63
+ const result = await probe();
64
+ const elapsed = performance.now() - start;
65
+ expect(result.ok).toBe(true);
66
+ // Parallel: ~80ms total. Sequential would be ~240ms. Generous margin for CI.
67
+ expect(elapsed).toBeLessThan(200);
68
+ });
69
+
70
+ test("empty check list → ok=true, empty results", async () => {
71
+ const probe = createReadinessProbe([]);
72
+ const result = await probe();
73
+ expect(result.ok).toBe(true);
74
+ expect(result.checks).toHaveLength(0);
75
+ });
76
+ });
@@ -0,0 +1,72 @@
1
+ import { Hono } from "hono";
2
+ import { describe, expect, test } from "vitest";
3
+ import { requestContext } from "../request-context";
4
+ import { requestIdMiddleware } from "../request-id-middleware";
5
+
6
+ describe("requestIdMiddleware — signal propagation", () => {
7
+ test("AbortSignal from c.req.raw lands in requestContext.signal", async () => {
8
+ let captured: { signal: AbortSignal | undefined; requestId: string | undefined } = {
9
+ signal: undefined,
10
+ requestId: undefined,
11
+ };
12
+
13
+ const app = new Hono();
14
+ app.use("/probe", requestIdMiddleware());
15
+ app.get("/probe", (c) => {
16
+ const ctx = requestContext.get();
17
+ captured = { signal: ctx?.signal, requestId: ctx?.requestId };
18
+ return c.text("ok");
19
+ });
20
+
21
+ const controller = new AbortController();
22
+ // Hono's app.request takes a Request OR a string + RequestInit. Pass
23
+ // a real Request so AbortSignal flows the way it does in production.
24
+ const res = await app.request(
25
+ new Request("http://test.local/probe", {
26
+ method: "GET",
27
+ signal: controller.signal,
28
+ }),
29
+ );
30
+
31
+ expect(res.status).toBe(200);
32
+ expect(captured.requestId).toBeDefined();
33
+ expect(captured.signal).toBeInstanceOf(AbortSignal);
34
+ expect(captured.signal?.aborted).toBe(false);
35
+ });
36
+
37
+ test("abort during handler execution flips ctx.signal.aborted to true", async () => {
38
+ // Handler holds the request open via a small delay. We fire abort()
39
+ // in the middle of that delay so the handler is guaranteed to be
40
+ // running when the signal flips — proves real propagation, not just
41
+ // "the field exists".
42
+ let captured: AbortSignal | undefined;
43
+
44
+ const app = new Hono();
45
+ app.use("/probe", requestIdMiddleware());
46
+ app.get("/probe", async (c) => {
47
+ captured = requestContext.get()?.signal;
48
+ await new Promise((resolve) => setTimeout(resolve, 60));
49
+ return c.text("ok");
50
+ });
51
+
52
+ const controller = new AbortController();
53
+ const fetchPromise = app.request(
54
+ new Request("http://test.local/probe", {
55
+ method: "GET",
56
+ signal: controller.signal,
57
+ }),
58
+ );
59
+ // Fire abort while the handler is awaiting the timeout.
60
+ setTimeout(() => controller.abort(), 20);
61
+
62
+ try {
63
+ await fetchPromise;
64
+ } catch {
65
+ // node may surface the abort as a thrown AbortError on the outer
66
+ // promise; we only care about the handler's view via captured.
67
+ }
68
+
69
+ expect(captured).toBeDefined();
70
+ expect(captured?.aborted).toBe(true);
71
+ });
72
+ });
@@ -0,0 +1,58 @@
1
+ import { describe, expect, test, vi } from "vitest";
2
+ import { createSseBroker, type SseEvent } from "../sse-broker";
3
+
4
+ describe("SSE broker", () => {
5
+ test("adds client and tracks count", () => {
6
+ const broker = createSseBroker();
7
+ broker.addClient("ch1", vi.fn(), vi.fn());
8
+ broker.addClient("ch1", vi.fn(), vi.fn());
9
+ broker.addClient("ch2", vi.fn(), vi.fn());
10
+
11
+ expect(broker.getClientCount("ch1")).toBe(2);
12
+ expect(broker.getClientCount("ch2")).toBe(1);
13
+ expect(broker.getTotalClientCount()).toBe(3);
14
+ });
15
+
16
+ test("pushToChannel sends to all clients on channel", () => {
17
+ const broker = createSseBroker();
18
+ const send1 = vi.fn();
19
+ const send2 = vi.fn();
20
+ const sendOther = vi.fn();
21
+
22
+ broker.addClient("users", send1, vi.fn());
23
+ broker.addClient("users", send2, vi.fn());
24
+ broker.addClient("other", sendOther, vi.fn());
25
+
26
+ const event: SseEvent = { type: "user.created", data: { id: 1 } };
27
+ broker.pushToChannel("users", event);
28
+
29
+ expect(send1).toHaveBeenCalledWith(event);
30
+ expect(send2).toHaveBeenCalledWith(event);
31
+ expect(sendOther).not.toHaveBeenCalled();
32
+ });
33
+
34
+ test("removeClient stops delivery", () => {
35
+ const broker = createSseBroker();
36
+ const send = vi.fn();
37
+
38
+ const clientId = broker.addClient("ch", send, vi.fn());
39
+ broker.removeClient("ch", clientId);
40
+
41
+ broker.pushToChannel("ch", { type: "test", data: {} });
42
+ expect(send).not.toHaveBeenCalled();
43
+ expect(broker.getClientCount("ch")).toBe(0);
44
+ });
45
+
46
+ test("pushToChannel to empty channel does nothing", () => {
47
+ const broker = createSseBroker();
48
+ // Should not throw
49
+ broker.pushToChannel("empty", { type: "test", data: {} });
50
+ expect(broker.getClientCount("empty")).toBe(0);
51
+ });
52
+
53
+ test("removeClient from unknown channel does nothing", () => {
54
+ const broker = createSseBroker();
55
+ expect(() => broker.removeClient("unknown", "fake-id")).not.toThrow();
56
+ expect(broker.getClientCount("unknown")).toBe(0);
57
+ });
58
+ });
@@ -0,0 +1,112 @@
1
+ import { Hono } from "hono";
2
+ import { describe, expect, test } from "vitest";
3
+ import { TestUsers } from "../../stack";
4
+ import { authMiddleware } from "../auth-middleware";
5
+ import { createJwtHelper } from "../jwt";
6
+ import type { SseBroker, SseEvent } from "../sse-broker";
7
+ import { createSseRoute } from "../sse-route";
8
+
9
+ const JWT_SECRET = "sse-route-unit-test-secret-at-least-32-characters";
10
+
11
+ function createTrackingBroker(): { broker: SseBroker; subscribedChannel: Promise<string> } {
12
+ let resolveChannel!: (channel: string) => void;
13
+ const subscribedChannel = new Promise<string>((resolve) => {
14
+ resolveChannel = resolve;
15
+ });
16
+
17
+ const broker: SseBroker = {
18
+ addClient(channel, _send, _close) {
19
+ resolveChannel(channel);
20
+ return "test-client-id";
21
+ },
22
+ removeClient() {},
23
+ pushToChannel(_channel: string, _event: SseEvent) {},
24
+ getClientCount() {
25
+ return 0;
26
+ },
27
+ getTotalClientCount() {
28
+ return 0;
29
+ },
30
+ };
31
+
32
+ return { broker, subscribedChannel };
33
+ }
34
+
35
+ async function buildSseApp(broker: SseBroker): Promise<{ app: Hono; token: string }> {
36
+ const jwt = createJwtHelper(JWT_SECRET);
37
+ const token = await jwt.sign(TestUsers.user); // tenantId = 1
38
+
39
+ const app = new Hono();
40
+ app.use("/api/*", authMiddleware(jwt));
41
+ app.route("/api", createSseRoute(broker));
42
+ return { app, token };
43
+ }
44
+
45
+ describe("sse-route security", () => {
46
+ test("subscribes to authenticated tenant channel, ignores client query-param", async () => {
47
+ const { broker, subscribedChannel } = createTrackingBroker();
48
+ const { app, token } = await buildSseApp(broker);
49
+
50
+ const controller = new AbortController();
51
+ // Stream keeps the request open — fire without awaiting, then abort.
52
+ // Promise.resolve() normalises Response | Promise<Response> to a thenable.
53
+ void Promise.resolve(
54
+ app.request("/api/sse?channel=tenant:999", {
55
+ headers: { Authorization: `Bearer ${token}` },
56
+ signal: controller.signal,
57
+ }),
58
+ ).catch(() => {
59
+ // Aborted — expected.
60
+ });
61
+
62
+ const channel = await subscribedChannel;
63
+ controller.abort();
64
+
65
+ expect(channel).toBe("tenant:00000000-0000-4000-8000-000000000001");
66
+ expect(channel).not.toBe("tenant:999");
67
+ });
68
+
69
+ test("subscribes to authenticated tenant channel even without any query-param", async () => {
70
+ const { broker, subscribedChannel } = createTrackingBroker();
71
+ const { app, token } = await buildSseApp(broker);
72
+
73
+ const controller = new AbortController();
74
+ void Promise.resolve(
75
+ app.request("/api/sse", {
76
+ headers: { Authorization: `Bearer ${token}` },
77
+ signal: controller.signal,
78
+ }),
79
+ ).catch(() => {});
80
+
81
+ const channel = await subscribedChannel;
82
+ controller.abort();
83
+
84
+ expect(channel).toBe("tenant:00000000-0000-4000-8000-000000000001");
85
+ });
86
+
87
+ test("rejects request without Bearer token", async () => {
88
+ const { broker } = createTrackingBroker();
89
+ const { app } = await buildSseApp(broker);
90
+
91
+ const res = await app.request("/api/sse");
92
+ expect(res.status).toBe(401);
93
+ });
94
+
95
+ test("cross-tenant injection attempt: user in tenant 1 cannot subscribe to tenant 2", async () => {
96
+ const { broker, subscribedChannel } = createTrackingBroker();
97
+ const { app, token } = await buildSseApp(broker); // token: tenantId 1
98
+
99
+ const controller = new AbortController();
100
+ void Promise.resolve(
101
+ app.request("/api/sse?channel=tenant:2&channel=tenant:3", {
102
+ headers: { Authorization: `Bearer ${token}` },
103
+ signal: controller.signal,
104
+ }),
105
+ ).catch(() => {});
106
+
107
+ const channel = await subscribedChannel;
108
+ controller.abort();
109
+
110
+ expect(channel).toBe("tenant:00000000-0000-4000-8000-000000000001");
111
+ });
112
+ });
@@ -0,0 +1,60 @@
1
+ // Server-side helper for the kumiko_tenant cookie used by the anonymous-
2
+ // access flow. Apps call setTenantCookie(c, tenantId) once a tenantResolver
3
+ // has determined the visitor's tenant, so subsequent requests skip the
4
+ // resolver (which usually does DB / cache lookups). deleteTenantCookie
5
+ // pairs with a switch-tenant or logout flow.
6
+ //
7
+ // The cookie is HttpOnly: the only consumer is the server-side auth-
8
+ // middleware on the next request — no client-side JS needs to read it,
9
+ // and not exposing it to JS keeps it out of XSS reach.
10
+
11
+ import type { Context } from "hono";
12
+ import { deleteCookie, setCookie } from "hono/cookie";
13
+ import type { TenantId } from "../engine/types";
14
+ import { TENANT_COOKIE_NAME } from "./api-constants";
15
+
16
+ // 30 days. The tenant assignment is stable for the lifetime of the
17
+ // visitor's relationship with the deployment — re-running the resolver
18
+ // on every visit would just hit the DB for no new information.
19
+ const DEFAULT_MAX_AGE_SECONDS = 30 * 24 * 60 * 60;
20
+
21
+ // Cookies marked Secure are dropped by browsers on http:// — locked off in
22
+ // dev/test so localhost works without TLS, on in prod where http would be
23
+ // a deployment bug we want to fail loud about (no cookie ⇒ resolver runs
24
+ // every request and the misconfiguration is visible in latency dashboards).
25
+ function cookieSecure(): boolean {
26
+ return process.env["NODE_ENV"] === "production";
27
+ }
28
+
29
+ export type SetTenantCookieOptions = {
30
+ // Override the default 30-day lifetime. Use shorter values for previews,
31
+ // longer for stable installations.
32
+ readonly maxAgeSeconds?: number;
33
+ // SameSite policy. Default "Lax" matches auth-cookie behaviour and
34
+ // permits cross-site GET-navigation (e.g. inbound link from a search
35
+ // result lands the visitor on the right tenant via the cookie).
36
+ readonly sameSite?: "Lax" | "Strict";
37
+ };
38
+
39
+ // Stamps the kumiko_tenant cookie on the response. Idempotent — calling it
40
+ // twice in the same request just overwrites with the latest value.
41
+ export function setTenantCookie(
42
+ c: Context,
43
+ tenantId: TenantId,
44
+ options: SetTenantCookieOptions = {},
45
+ ): void {
46
+ setCookie(c, TENANT_COOKIE_NAME, tenantId, {
47
+ httpOnly: true,
48
+ secure: cookieSecure(),
49
+ sameSite: options.sameSite ?? "Lax",
50
+ path: "/",
51
+ maxAge: options.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS,
52
+ });
53
+ }
54
+
55
+ // Removes the kumiko_tenant cookie. Use on switch-tenant flows or when
56
+ // the resolver no longer recognises the visitor's tenant — leaving a
57
+ // stale cookie behind would keep them pointed at a deleted tenant.
58
+ export function deleteTenantCookie(c: Context): void {
59
+ deleteCookie(c, TENANT_COOKIE_NAME, { path: "/" });
60
+ }
@@ -0,0 +1,64 @@
1
+ // API-internal constants. Features should never need these —
2
+ // they register handlers, the framework handles routing.
3
+
4
+ export const Routes = {
5
+ health: "/health",
6
+ healthReady: "/health/ready",
7
+ version: "/version",
8
+ write: "/write",
9
+ batch: "/batch",
10
+ query: "/query",
11
+ command: "/command",
12
+ sse: "/sse",
13
+ auth: "/auth",
14
+ authLogin: "/auth/login",
15
+ authLogout: "/auth/logout",
16
+ authTenants: "/auth/tenants",
17
+ authSwitchTenant: "/auth/switch-tenant",
18
+ authRequestPasswordReset: "/auth/request-password-reset",
19
+ authResetPassword: "/auth/reset-password",
20
+ authRequestEmailVerification: "/auth/request-email-verification",
21
+ authVerifyEmail: "/auth/verify-email",
22
+ authSignupRequest: "/auth/signup-request",
23
+ authSignupConfirm: "/auth/signup-confirm",
24
+ // Tenant-Invite (Magic-Link): 3 separate accept-Endpoints für klare
25
+ // Branch-Separation. Plus invite-info als public-readable details
26
+ // damit das Frontend "Du wirst eingeladen zu Tenant X als Role Y"
27
+ // anzeigen kann bevor der User submitted.
28
+ authInviteAccept: "/auth/invite-accept",
29
+ authInviteAcceptWithLogin: "/auth/invite-accept-with-login",
30
+ authInviteSignupComplete: "/auth/invite-signup-complete",
31
+ authInviteInfo: "/auth/invite-info",
32
+ files: "/files",
33
+ } as const;
34
+
35
+ // Routes that must be reachable WITHOUT a valid JWT.
36
+ // The auth middleware skips these paths.
37
+ export const PUBLIC_API_PATHS: ReadonlySet<string> = new Set([
38
+ `/api${Routes.authLogin}`,
39
+ `/api${Routes.authRequestPasswordReset}`,
40
+ `/api${Routes.authResetPassword}`,
41
+ `/api${Routes.authRequestEmailVerification}`,
42
+ `/api${Routes.authVerifyEmail}`,
43
+ `/api${Routes.authSignupRequest}`,
44
+ `/api${Routes.authSignupConfirm}`,
45
+ // invite-accept braucht JWT (logged-in User, Branch 1) — NICHT public.
46
+ // invite-accept-with-login (Branch 2) und invite-signup-complete
47
+ // (Branch 3) sind anonymous, brauchen public-skip.
48
+ `/api${Routes.authInviteAcceptWithLogin}`,
49
+ `/api${Routes.authInviteSignupComplete}`,
50
+ `/api${Routes.authInviteInfo}`,
51
+ `/api${Routes.health}`,
52
+ `/api${Routes.healthReady}`,
53
+ `/api${Routes.version}`,
54
+ ]);
55
+
56
+ // Tenant transports for unauthenticated callers on public endpoints. JWT
57
+ // users carry tenantId in the signed token; anonymous callers must declare
58
+ // the tenant out-of-band — header for SPA/mobile, cookie for browser-direct
59
+ // access. The middleware reads header first, then cookie, then falls back to
60
+ // `anonymousAccess.tenantResolver` and finally `anonymousAccess.defaultTenantId`.
61
+ export const TENANT_HEADER_NAME = "X-Tenant";
62
+ export const TENANT_COOKIE_NAME = "kumiko_tenant";
63
+
64
+ export type Route = (typeof Routes)[keyof typeof Routes];