@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,267 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { validateBoot } from "../boot-validator";
3
+ import { defineFeature } from "../define-feature";
4
+ import { createEntity, createTextField } from "../factories";
5
+ import { createRegistry } from "../registry";
6
+
7
+ function productEntity() {
8
+ return createEntity({
9
+ table: "products",
10
+ fields: { name: createTextField() },
11
+ });
12
+ }
13
+
14
+ describe("r.nav() — registration", () => {
15
+ test("stores a minimal nav entry", () => {
16
+ const feature = defineFeature("shop", (r) => {
17
+ r.nav({ id: "catalog", label: "shop:nav.catalog" });
18
+ });
19
+ expect(feature.navs["catalog"]).toBeDefined();
20
+ expect(feature.navs["catalog"]?.label).toBe("shop:nav.catalog");
21
+ });
22
+
23
+ test("stores a nav entry with icon, order, parent, screen, access", () => {
24
+ const feature = defineFeature("shop", (r) => {
25
+ r.entity("product", productEntity());
26
+ r.screen({
27
+ id: "products",
28
+ type: "entityList",
29
+ entity: "product",
30
+ columns: ["name"],
31
+ });
32
+ r.nav({ id: "catalog", label: "shop:nav.catalog" });
33
+ r.nav({
34
+ id: "products",
35
+ label: "shop:nav.products",
36
+ icon: "box",
37
+ order: 10,
38
+ parent: "shop:nav:catalog",
39
+ screen: "shop:screen:products",
40
+ access: { roles: ["Admin"] },
41
+ });
42
+ });
43
+ const nav = feature.navs["products"];
44
+ expect(nav).toMatchObject({
45
+ icon: "box",
46
+ order: 10,
47
+ parent: "shop:nav:catalog",
48
+ screen: "shop:screen:products",
49
+ });
50
+ });
51
+
52
+ test("rejects duplicate nav ids within the same feature", () => {
53
+ expect(() =>
54
+ defineFeature("shop", (r) => {
55
+ r.nav({ id: "catalog", label: "a" });
56
+ r.nav({ id: "catalog", label: "b" });
57
+ }),
58
+ ).toThrow(/already registered/);
59
+ });
60
+
61
+ test("rejects non-kebab-case nav ids", () => {
62
+ expect(() =>
63
+ defineFeature("shop", (r) => {
64
+ r.nav({ id: "Catalog", label: "x" });
65
+ }),
66
+ ).toThrow(/kebab-case/);
67
+ });
68
+
69
+ test("accepts kebab-case nav ids", () => {
70
+ expect(() =>
71
+ defineFeature("shop", (r) => {
72
+ r.nav({ id: "main-catalog", label: "x" });
73
+ }),
74
+ ).not.toThrow();
75
+ });
76
+ });
77
+
78
+ describe("createRegistry — nav indexing", () => {
79
+ test("indexes nav entries by qualified name", () => {
80
+ const feature = defineFeature("shop", (r) => {
81
+ r.nav({ id: "catalog", label: "x" });
82
+ });
83
+ const registry = createRegistry([feature]);
84
+ expect(registry.getAllNavs().size).toBe(1);
85
+ expect(registry.getNav("shop:nav:catalog")).toBeDefined();
86
+ });
87
+
88
+ test("returns undefined for unknown qualified nav names", () => {
89
+ const registry = createRegistry([]);
90
+ expect(registry.getNav("ghost:nav:nope")).toBeUndefined();
91
+ });
92
+
93
+ test("getNavFeature maps a nav entry back to its owning feature", () => {
94
+ const feature = defineFeature("shop", (r) => {
95
+ r.nav({ id: "catalog", label: "x" });
96
+ });
97
+ const registry = createRegistry([feature]);
98
+ expect(registry.getNavFeature("shop:nav:catalog")).toBe("shop");
99
+ expect(registry.getNavFeature("shop:nav:nope")).toBeUndefined();
100
+ });
101
+
102
+ test("same nav id from two features qualifies to different names", () => {
103
+ const shop = defineFeature("shop", (r) => {
104
+ r.nav({ id: "home", label: "x" });
105
+ });
106
+ const settings = defineFeature("settings", (r) => {
107
+ r.nav({ id: "home", label: "y" });
108
+ });
109
+ const registry = createRegistry([shop, settings]);
110
+ expect(registry.getAllNavs().size).toBe(2);
111
+ expect(registry.getNav("shop:nav:home")).toBeDefined();
112
+ expect(registry.getNav("settings:nav:home")).toBeDefined();
113
+ });
114
+
115
+ test("getTopLevelNavs + getNavsByParent partition the tree cleanly", () => {
116
+ const feature = defineFeature("shop", (r) => {
117
+ r.nav({ id: "root", label: "r" });
118
+ r.nav({ id: "mid", label: "m", parent: "shop:nav:root" });
119
+ r.nav({ id: "leaf-a", label: "a", parent: "shop:nav:mid" });
120
+ r.nav({ id: "leaf-b", label: "b", parent: "shop:nav:mid" });
121
+ r.nav({ id: "other-root", label: "r2" });
122
+ });
123
+ const registry = createRegistry([feature]);
124
+
125
+ // Two top-level entries (root + other-root), registration order preserved.
126
+ // Stored ids are qualified — caller can feed them straight back into
127
+ // getNavsByParent without a reverse index.
128
+ const tops = registry.getTopLevelNavs();
129
+ expect(tops.map((n) => n.id)).toEqual(["shop:nav:root", "shop:nav:other-root"]);
130
+
131
+ // Direct children of root (just `mid`).
132
+ const rootChildren = registry.getNavsByParent("shop:nav:root");
133
+ expect(rootChildren.map((n) => n.id)).toEqual(["shop:nav:mid"]);
134
+
135
+ // Direct children of mid (leaf-a + leaf-b, order preserved).
136
+ const midChildren = registry.getNavsByParent("shop:nav:mid");
137
+ expect(midChildren.map((n) => n.id)).toEqual(["shop:nav:leaf-a", "shop:nav:leaf-b"]);
138
+
139
+ // Unknown parent → empty.
140
+ expect(registry.getNavsByParent("ghost:nav:nope")).toHaveLength(0);
141
+ });
142
+
143
+ test("getNavsByParent aggregates cross-feature children under a shared parent", () => {
144
+ const shell = defineFeature("shell", (r) => {
145
+ r.nav({ id: "main", label: "x" });
146
+ });
147
+ const shop = defineFeature("shop", (r) => {
148
+ r.nav({ id: "catalog", label: "y", parent: "shell:nav:main" });
149
+ });
150
+ const settings = defineFeature("settings", (r) => {
151
+ r.nav({ id: "general", label: "z", parent: "shell:nav:main" });
152
+ });
153
+ const registry = createRegistry([shell, shop, settings]);
154
+ const children = registry.getNavsByParent("shell:nav:main");
155
+ expect(children.map((n) => n.id).sort()).toEqual(["settings:nav:general", "shop:nav:catalog"]);
156
+ });
157
+
158
+ test("getNav / getAllNavs / getTopLevelNavs return stored navs with qualified id", () => {
159
+ const feature = defineFeature("shop", (r) => {
160
+ r.nav({ id: "catalog", label: "x" });
161
+ });
162
+ const registry = createRegistry([feature]);
163
+ // Input-side keeps the short id; only registry-side exposes qualified.
164
+ expect(feature.navs["catalog"]?.id).toBe("catalog");
165
+ expect(registry.getNav("shop:nav:catalog")?.id).toBe("shop:nav:catalog");
166
+ expect(registry.getTopLevelNavs()[0]?.id).toBe("shop:nav:catalog");
167
+ });
168
+ });
169
+
170
+ describe("validateBoot — nav validation", () => {
171
+ test("nav referencing an unknown screen fails boot", () => {
172
+ const feature = defineFeature("shop", (r) => {
173
+ r.nav({
174
+ id: "catalog",
175
+ label: "x",
176
+ screen: "shop:screen:does-not-exist",
177
+ });
178
+ });
179
+ expect(() => validateBoot([feature])).toThrow(/references screen "shop:screen:does-not-exist"/);
180
+ });
181
+
182
+ test("nav referencing an unknown parent fails boot", () => {
183
+ const feature = defineFeature("shop", (r) => {
184
+ r.nav({
185
+ id: "products",
186
+ label: "x",
187
+ parent: "shop:nav:does-not-exist",
188
+ });
189
+ });
190
+ expect(() => validateBoot([feature])).toThrow(/references parent "shop:nav:does-not-exist"/);
191
+ });
192
+
193
+ test("nav with valid screen + parent refs passes boot", () => {
194
+ const feature = defineFeature("shop", (r) => {
195
+ r.entity("product", productEntity());
196
+ r.screen({
197
+ id: "products",
198
+ type: "entityList",
199
+ entity: "product",
200
+ columns: ["name"],
201
+ });
202
+ r.nav({ id: "catalog", label: "x" });
203
+ r.nav({
204
+ id: "products",
205
+ label: "y",
206
+ parent: "shop:nav:catalog",
207
+ screen: "shop:screen:products",
208
+ });
209
+ });
210
+ expect(() => validateBoot([feature])).not.toThrow();
211
+ });
212
+
213
+ test("nav can reference a screen registered by another feature", () => {
214
+ const shop = defineFeature("shop", (r) => {
215
+ r.entity("product", productEntity());
216
+ r.screen({
217
+ id: "products",
218
+ type: "entityList",
219
+ entity: "product",
220
+ columns: ["name"],
221
+ });
222
+ });
223
+ const menu = defineFeature("menu", (r) => {
224
+ r.nav({ id: "shop-entry", label: "x", screen: "shop:screen:products" });
225
+ });
226
+ expect(() => validateBoot([shop, menu])).not.toThrow();
227
+ });
228
+
229
+ test("nav can reference a parent registered by another feature", () => {
230
+ const shell = defineFeature("shell", (r) => {
231
+ r.nav({ id: "main", label: "x" });
232
+ });
233
+ const shop = defineFeature("shop", (r) => {
234
+ r.nav({ id: "catalog", label: "y", parent: "shell:nav:main" });
235
+ });
236
+ expect(() => validateBoot([shell, shop])).not.toThrow();
237
+ });
238
+
239
+ test("direct parent cycle (a → a) fails boot", () => {
240
+ const feature = defineFeature("shop", (r) => {
241
+ r.nav({ id: "self", label: "x", parent: "shop:nav:self" });
242
+ });
243
+ expect(() => validateBoot([feature])).toThrow(/parent cycle/);
244
+ });
245
+
246
+ test("two-step parent cycle (a → b → a) fails boot", () => {
247
+ // defineFeature can't close the cycle in one feature because the second
248
+ // r.nav needs the first to already exist with the cycle-closing parent.
249
+ // Hand-build two features whose refs point at each other.
250
+ const a = defineFeature("a", (r) => {
251
+ r.nav({ id: "one", label: "x", parent: "b:nav:two" });
252
+ });
253
+ const b = defineFeature("b", (r) => {
254
+ r.nav({ id: "two", label: "y", parent: "a:nav:one" });
255
+ });
256
+ expect(() => validateBoot([a, b])).toThrow(/parent cycle/);
257
+ });
258
+
259
+ test("three-step parent chain without cycle passes", () => {
260
+ const feature = defineFeature("shop", (r) => {
261
+ r.nav({ id: "root", label: "r" });
262
+ r.nav({ id: "mid", label: "m", parent: "shop:nav:root" });
263
+ r.nav({ id: "leaf", label: "l", parent: "shop:nav:mid" });
264
+ });
265
+ expect(() => validateBoot([feature])).not.toThrow();
266
+ });
267
+ });
@@ -0,0 +1,421 @@
1
+ import { sql } from "drizzle-orm";
2
+ import { describe, expect, test } from "vitest";
3
+ import {
4
+ buildOwnershipClause,
5
+ from,
6
+ matchesRule,
7
+ type OwnershipMap,
8
+ userCanCreateFieldRow,
9
+ userCanReadFieldRow,
10
+ userCanWriteFieldRow,
11
+ } from "../ownership";
12
+ import type { SessionUser } from "../types";
13
+
14
+ // Helper — builds SessionUser with optional claims.
15
+ function mkUser(
16
+ overrides: Partial<SessionUser> & { claims?: Record<string, unknown> } = {},
17
+ ): SessionUser {
18
+ return {
19
+ id: overrides.id ?? "11111111-0000-4000-8000-000000000001",
20
+ tenantId: overrides.tenantId ?? "22222222-0000-4000-8000-000000000001",
21
+ roles: overrides.roles ?? ["User"],
22
+ ...(overrides.claims ? { claims: overrides.claims } : {}),
23
+ };
24
+ }
25
+
26
+ // --- from() parser ---
27
+
28
+ describe("from() — ref parsing", () => {
29
+ test("user:id + explicit column", () => {
30
+ const r = from("user:id", "assigneeId");
31
+ expect(r).toEqual({
32
+ kind: "from",
33
+ refKind: "user",
34
+ refPath: "id",
35
+ column: "assigneeId",
36
+ });
37
+ });
38
+
39
+ test("user:tenantId + explicit column", () => {
40
+ const r = from("user:tenantId", "tenantId");
41
+ expect(r.refKind).toBe("user");
42
+ expect(r.refPath).toBe("tenantId");
43
+ });
44
+
45
+ test("user-ref without column throws (column required for user-refs)", () => {
46
+ expect(() => from("user:id")).toThrow(/require an explicit column/);
47
+ });
48
+
49
+ test("unknown user-ref throws", () => {
50
+ expect(() => from("user:email", "email")).toThrow(/supports only "user:id" or "user:tenantId"/);
51
+ });
52
+
53
+ test("claim-ref: default column = claim shortName", () => {
54
+ const r = from("claim:teams:teamId");
55
+ expect(r).toEqual({
56
+ kind: "from",
57
+ refKind: "claim",
58
+ refPath: "teams:teamId",
59
+ column: "teamId", // second segment, auto-derived
60
+ });
61
+ });
62
+
63
+ test("claim-ref: explicit column override", () => {
64
+ const r = from("claim:teams:teamId", "teamIdFk");
65
+ expect(r.column).toBe("teamIdFk");
66
+ expect(r.refPath).toBe("teams:teamId"); // full QN still preserved
67
+ });
68
+
69
+ test("claim-ref without 2 segments throws", () => {
70
+ expect(() => from("claim:teams")).toThrow(/must be "claim:<featureName>:<shortName>"/);
71
+ });
72
+
73
+ test("unknown prefix throws", () => {
74
+ expect(() => from("group:admins")).toThrow(/unsupported ref prefix "group"/);
75
+ });
76
+
77
+ test("no colon throws", () => {
78
+ expect(() => from("teamId")).toThrow(/no colon found/);
79
+ });
80
+ });
81
+
82
+ // --- matchesRule() — row-level evaluation ---
83
+
84
+ describe("matchesRule() — claim scalar", () => {
85
+ test("user claim matches row column → true", () => {
86
+ const rule = from("claim:teams:teamId");
87
+ const user = mkUser({ claims: { "teams:teamId": "eng" } });
88
+ expect(matchesRule(rule, user, { teamId: "eng" })).toBe(true);
89
+ });
90
+
91
+ test("user claim mismatches row column → false", () => {
92
+ const rule = from("claim:teams:teamId");
93
+ const user = mkUser({ claims: { "teams:teamId": "eng" } });
94
+ expect(matchesRule(rule, user, { teamId: "ops" })).toBe(false);
95
+ });
96
+
97
+ test("user has no claim → false (never match)", () => {
98
+ const rule = from("claim:teams:teamId");
99
+ const user = mkUser({ claims: {} });
100
+ expect(matchesRule(rule, user, { teamId: "eng" })).toBe(false);
101
+ });
102
+
103
+ test("row column is null → false (safer default than null-match)", () => {
104
+ const rule = from("claim:teams:teamId");
105
+ const user = mkUser({ claims: { "teams:teamId": "eng" } });
106
+ expect(matchesRule(rule, user, { teamId: null })).toBe(false);
107
+ });
108
+ });
109
+
110
+ describe("matchesRule() — claim array (inArray semantics)", () => {
111
+ test("row value IN user's array → true", () => {
112
+ const rule = from("claim:teams:teamIds", "teamId");
113
+ const user = mkUser({ claims: { "teams:teamIds": ["eng", "ops"] } });
114
+ expect(matchesRule(rule, user, { teamId: "eng" })).toBe(true);
115
+ expect(matchesRule(rule, user, { teamId: "ops" })).toBe(true);
116
+ });
117
+
118
+ test("row value NOT in user's array → false", () => {
119
+ const rule = from("claim:teams:teamIds", "teamId");
120
+ const user = mkUser({ claims: { "teams:teamIds": ["eng", "ops"] } });
121
+ expect(matchesRule(rule, user, { teamId: "sales" })).toBe(false);
122
+ });
123
+
124
+ test("empty user array → false for any row", () => {
125
+ const rule = from("claim:teams:teamIds", "teamId");
126
+ const user = mkUser({ claims: { "teams:teamIds": [] } });
127
+ expect(matchesRule(rule, user, { teamId: "eng" })).toBe(false);
128
+ });
129
+ });
130
+
131
+ describe("matchesRule() — user:id", () => {
132
+ test("row column equals user.id → true", () => {
133
+ const rule = from("user:id", "assigneeId");
134
+ const user = mkUser({ id: "u-42" });
135
+ expect(matchesRule(rule, user, { assigneeId: "u-42" })).toBe(true);
136
+ });
137
+
138
+ test("row column differs from user.id → false", () => {
139
+ const rule = from("user:id", "assigneeId");
140
+ const user = mkUser({ id: "u-42" });
141
+ expect(matchesRule(rule, user, { assigneeId: "u-7" })).toBe(false);
142
+ });
143
+ });
144
+
145
+ describe("matchesRule() — 'all'", () => {
146
+ test("always passes", () => {
147
+ const user = mkUser();
148
+ expect(matchesRule("all", user, { whatever: "anything" })).toBe(true);
149
+ expect(matchesRule("all", user, {})).toBe(true);
150
+ });
151
+ });
152
+
153
+ // --- userCanReadFieldRow() — multi-role, per-role OR ---
154
+
155
+ describe("userCanReadFieldRow() — multi-role OR", () => {
156
+ const accessMap: OwnershipMap = {
157
+ Admin: "all",
158
+ TeamMember: from("claim:teams:teamId"),
159
+ };
160
+
161
+ test("Admin passes regardless of row ('all')", () => {
162
+ const user = mkUser({ roles: ["Admin"] });
163
+ expect(userCanReadFieldRow(user, accessMap, { teamId: "ops" })).toBe(true);
164
+ });
165
+
166
+ test("TeamMember passes when claim matches", () => {
167
+ const user = mkUser({ roles: ["TeamMember"], claims: { "teams:teamId": "eng" } });
168
+ expect(userCanReadFieldRow(user, accessMap, { teamId: "eng" })).toBe(true);
169
+ });
170
+
171
+ test("TeamMember blocked when claim mismatches", () => {
172
+ const user = mkUser({ roles: ["TeamMember"], claims: { "teams:teamId": "eng" } });
173
+ expect(userCanReadFieldRow(user, accessMap, { teamId: "ops" })).toBe(false);
174
+ });
175
+
176
+ test("user with role NOT in map → blocked", () => {
177
+ const user = mkUser({ roles: ["Guest"] });
178
+ expect(userCanReadFieldRow(user, accessMap, { teamId: "eng" })).toBe(false);
179
+ });
180
+
181
+ test("undefined access map → public (always read)", () => {
182
+ const user = mkUser({ roles: [] });
183
+ expect(userCanReadFieldRow(user, undefined, { teamId: "eng" })).toBe(true);
184
+ });
185
+
186
+ test("empty access map → public (no rules = no restriction)", () => {
187
+ const user = mkUser({ roles: ["Admin"] });
188
+ expect(userCanReadFieldRow(user, {}, { teamId: "eng" })).toBe(true);
189
+ });
190
+
191
+ test("multi-role: user has Admin AND TeamMember → Admin wins (short-circuit on 'all')", () => {
192
+ const user = mkUser({
193
+ roles: ["TeamMember", "Admin"],
194
+ claims: { "teams:teamId": "eng" },
195
+ });
196
+ // row with mismatched teamId — TeamMember would fail, Admin passes
197
+ expect(userCanReadFieldRow(user, accessMap, { teamId: "ops" })).toBe(true);
198
+ });
199
+ });
200
+
201
+ // --- userCanWriteFieldRow() — STRADDLE PREVENTION ---
202
+
203
+ describe("userCanWriteFieldRow() — Straddle-attack prevention", () => {
204
+ // Critical test from the advisor review: a user with two roles,
205
+ // each role's rule matches only ONE of (old, new). An aggregated check
206
+ // (any-role passes old) AND (any-role passes new) would wrongly allow.
207
+ // The correct atomic check requires ONE role whose rule passes BOTH.
208
+
209
+ const accessMap: OwnershipMap = {
210
+ Driver: from("user:id", "assigneeId"),
211
+ Manager: from("claim:teams:teamId"),
212
+ };
213
+
214
+ test("SECURITY: user with [Driver, Manager], old matches only Driver, new matches only Manager → BLOCKED", () => {
215
+ const user = mkUser({
216
+ id: "me",
217
+ roles: ["Driver", "Manager"],
218
+ claims: { "teams:teamId": "myTeam" },
219
+ });
220
+ const oldRow = { assigneeId: "me", teamId: "otherTeam" }; // Driver ✓, Manager ✗
221
+ const newRow = { assigneeId: "other", teamId: "myTeam" }; // Driver ✗, Manager ✓
222
+ // Per-role atomic: no single role passes both → BLOCKED
223
+ expect(userCanWriteFieldRow(user, accessMap, oldRow, newRow)).toBe(false);
224
+ });
225
+
226
+ test("single role passing both old AND new → allowed", () => {
227
+ const user = mkUser({ roles: ["Driver"], id: "me" });
228
+ const oldRow = { assigneeId: "me", teamId: "any" };
229
+ const newRow = { assigneeId: "me", teamId: "any2" }; // still me, just a different team
230
+ expect(userCanWriteFieldRow(user, accessMap, oldRow, newRow)).toBe(true);
231
+ });
232
+
233
+ test("role passes old but not new → blocked (attempted row-grab via column change)", () => {
234
+ const user = mkUser({ roles: ["Driver"], id: "me" });
235
+ const oldRow = { assigneeId: "me" };
236
+ const newRow = { assigneeId: "other" }; // tried to reassign to someone else
237
+ expect(userCanWriteFieldRow(user, accessMap, oldRow, newRow)).toBe(false);
238
+ });
239
+
240
+ test("role passes new but not old → blocked (attempted grab of foreign row)", () => {
241
+ const user = mkUser({ roles: ["Driver"], id: "me" });
242
+ const oldRow = { assigneeId: "other" }; // this is not my row
243
+ const newRow = { assigneeId: "me" }; // I set it to mine
244
+ expect(userCanWriteFieldRow(user, accessMap, oldRow, newRow)).toBe(false);
245
+ });
246
+
247
+ test("'all' rule skips row-check entirely", () => {
248
+ const user = mkUser({ roles: ["Admin"] });
249
+ const map: OwnershipMap = { Admin: "all" };
250
+ expect(userCanWriteFieldRow(user, map, { any: "old" }, { any: "new" })).toBe(true);
251
+ });
252
+
253
+ test("user with role NOT in map → blocked", () => {
254
+ const user = mkUser({ roles: ["Guest"] });
255
+ expect(userCanWriteFieldRow(user, accessMap, { assigneeId: "me" }, { assigneeId: "me" })).toBe(
256
+ false,
257
+ );
258
+ });
259
+
260
+ test("undefined access map → public (always write)", () => {
261
+ const user = mkUser();
262
+ expect(userCanWriteFieldRow(user, undefined, {}, {})).toBe(true);
263
+ });
264
+ });
265
+
266
+ // --- userCanCreateFieldRow() ---
267
+
268
+ // --- buildOwnershipClause() — SQL WHERE builder ---
269
+
270
+ describe("buildOwnershipClause() — SQL WHERE builder", () => {
271
+ // Fake Drizzle table — the builder only needs `table[columnName]` to
272
+ // resolve an opaque column reference, and hands it to eq()/inArray().
273
+ // We don't assert on the serialized SQL text (dialect-dependent); we
274
+ // assert on the diskriminated OwnershipClause return.
275
+ const fakeTable = {
276
+ teamId: sql.raw("team_id"),
277
+ assigneeId: sql.raw("assignee_id"),
278
+ tenantId: sql.raw("tenant_id"),
279
+ };
280
+
281
+ test("undefined access map → pass (public entity)", () => {
282
+ const user = mkUser();
283
+ expect(buildOwnershipClause(user, undefined, fakeTable).kind).toBe("pass");
284
+ });
285
+
286
+ test("empty access map → pass (no rules = no restriction)", () => {
287
+ const user = mkUser();
288
+ expect(buildOwnershipClause(user, {}, fakeTable).kind).toBe("pass");
289
+ });
290
+
291
+ test("user role with 'all' → pass (unrestricted, short-circuit)", () => {
292
+ const user = mkUser({ roles: ["Admin"] });
293
+ const map: OwnershipMap = { Admin: "all" };
294
+ expect(buildOwnershipClause(user, map, fakeTable).kind).toBe("pass");
295
+ });
296
+
297
+ test("multi-role: any role with 'all' short-circuits to pass", () => {
298
+ const user = mkUser({ roles: ["TeamMember", "Admin"] });
299
+ const map: OwnershipMap = {
300
+ Admin: "all",
301
+ TeamMember: from("claim:teams:teamId"),
302
+ };
303
+ expect(buildOwnershipClause(user, map, fakeTable).kind).toBe("pass");
304
+ });
305
+
306
+ test("user has no matching role in map → empty (0 rows returned)", () => {
307
+ const user = mkUser({ roles: ["Guest"] });
308
+ const map: OwnershipMap = { Admin: "all", TeamMember: from("claim:teams:teamId") };
309
+ expect(buildOwnershipClause(user, map, fakeTable).kind).toBe("empty");
310
+ });
311
+
312
+ test("claim-rule with matching claim → sql-clause", () => {
313
+ const user = mkUser({
314
+ roles: ["TeamMember"],
315
+ claims: { "teams:teamId": "eng" },
316
+ });
317
+ const map: OwnershipMap = { TeamMember: from("claim:teams:teamId") };
318
+ const clause = buildOwnershipClause(user, map, fakeTable);
319
+ expect(clause.kind).toBe("sql");
320
+ });
321
+
322
+ test("array claim with values → sql-clause (inArray)", () => {
323
+ const user = mkUser({
324
+ roles: ["TeamMember"],
325
+ claims: { "teams:teamIds": ["eng", "ops"] },
326
+ });
327
+ const map: OwnershipMap = { TeamMember: from("claim:teams:teamIds", "teamId") };
328
+ expect(buildOwnershipClause(user, map, fakeTable).kind).toBe("sql");
329
+ });
330
+
331
+ test("empty array claim → empty (no rows match an empty set)", () => {
332
+ const user = mkUser({
333
+ roles: ["TeamMember"],
334
+ claims: { "teams:teamIds": [] },
335
+ });
336
+ const map: OwnershipMap = { TeamMember: from("claim:teams:teamIds", "teamId") };
337
+ expect(buildOwnershipClause(user, map, fakeTable).kind).toBe("empty");
338
+ });
339
+
340
+ test("missing claim → empty (user has role but no claim value)", () => {
341
+ const user = mkUser({ roles: ["TeamMember"], claims: {} });
342
+ const map: OwnershipMap = { TeamMember: from("claim:teams:teamId") };
343
+ expect(buildOwnershipClause(user, map, fakeTable).kind).toBe("empty");
344
+ });
345
+
346
+ test("multi-role with mixed claims: one role has claim, other doesn't → sql (OR of passing branches)", () => {
347
+ // Driver-rule always passes against a row where assigneeId exists.
348
+ // Manager-rule collapses to empty (no teamId claim). Result: sql-clause
349
+ // for Driver only, since the Manager branch dropped out.
350
+ const user = mkUser({
351
+ id: "me",
352
+ roles: ["Driver", "Manager"],
353
+ claims: {},
354
+ });
355
+ const map: OwnershipMap = {
356
+ Driver: from("user:id", "assigneeId"),
357
+ Manager: from("claim:teams:teamId"),
358
+ };
359
+ expect(buildOwnershipClause(user, map, fakeTable).kind).toBe("sql");
360
+ });
361
+
362
+ test("multi-role all-collapsed-to-empty → empty (every rule dropped)", () => {
363
+ // User has TeamMember + Viewer. Both rules need claims the user doesn't have.
364
+ const user = mkUser({ roles: ["TeamMember", "Viewer"], claims: {} });
365
+ const map: OwnershipMap = {
366
+ TeamMember: from("claim:teams:teamId"),
367
+ Viewer: from("claim:scopes:region"),
368
+ };
369
+ expect(buildOwnershipClause(user, map, fakeTable).kind).toBe("empty");
370
+ });
371
+
372
+ test("where-rule escape hatch: caller's SQL passed through", () => {
373
+ const user = mkUser({ roles: ["Auditor"] });
374
+ const map: OwnershipMap = {
375
+ Auditor: { kind: "where", where: () => sql`custom_expr_42 = 1` },
376
+ };
377
+ const clause = buildOwnershipClause(user, map, fakeTable);
378
+ expect(clause.kind).toBe("sql");
379
+ });
380
+
381
+ test("unknown column (boot-validator would have caught) → empty fail-closed", () => {
382
+ const user = mkUser({
383
+ roles: ["TeamMember"],
384
+ claims: { "teams:teamId": "eng" },
385
+ });
386
+ const map: OwnershipMap = {
387
+ TeamMember: from("claim:teams:teamId", "nonExistentColumn"),
388
+ };
389
+ // Column not on fakeTable — builder returns empty rather than passing
390
+ // the request through as unrestricted.
391
+ expect(buildOwnershipClause(user, map, fakeTable).kind).toBe("empty");
392
+ });
393
+ });
394
+
395
+ describe("userCanCreateFieldRow() — create case (no old row)", () => {
396
+ const accessMap: OwnershipMap = {
397
+ Admin: "all",
398
+ TeamMember: from("claim:teams:teamId"),
399
+ };
400
+
401
+ test("TeamMember creating row with matching teamId → allowed", () => {
402
+ const user = mkUser({
403
+ roles: ["TeamMember"],
404
+ claims: { "teams:teamId": "eng" },
405
+ });
406
+ expect(userCanCreateFieldRow(user, accessMap, { teamId: "eng" })).toBe(true);
407
+ });
408
+
409
+ test("TeamMember creating row with foreign teamId → blocked", () => {
410
+ const user = mkUser({
411
+ roles: ["TeamMember"],
412
+ claims: { "teams:teamId": "eng" },
413
+ });
414
+ expect(userCanCreateFieldRow(user, accessMap, { teamId: "ops" })).toBe(false);
415
+ });
416
+
417
+ test("Admin 'all' creates anything", () => {
418
+ const user = mkUser({ roles: ["Admin"] });
419
+ expect(userCanCreateFieldRow(user, accessMap, { teamId: "ops" })).toBe(true);
420
+ });
421
+ });