@jini-ai/cms 0.3.5 → 0.3.7

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 (325) hide show
  1. package/README.md +22 -17
  2. package/dist/content-types/agent-tools.js +1 -1
  3. package/dist/content-types/errors.d.ts +19 -1
  4. package/dist/content-types/errors.js +22 -1
  5. package/dist/content-types/index-provisioning.d.ts +56 -10
  6. package/dist/content-types/index-provisioning.js +86 -13
  7. package/dist/content-types/index.d.ts +15 -3
  8. package/dist/content-types/index.js +14 -2
  9. package/dist/content-types/lifecycle.js +3 -3
  10. package/dist/content-types/types.d.ts +64 -3
  11. package/dist/content-types/types.js +67 -3
  12. package/dist/content-types/write-service.d.ts +18 -3
  13. package/dist/content-types/write-service.js +28 -2
  14. package/dist/core/ports.d.ts +12 -2
  15. package/dist/entries/field-validation.js +96 -1
  16. package/dist/entries/write-service.d.ts +14 -0
  17. package/dist/entries/write-service.js +2 -2
  18. package/dist/identity/admin-crud-service.d.ts +28 -0
  19. package/dist/identity/admin-crud-service.js +30 -0
  20. package/dist/identity/auth-service.d.ts +32 -1
  21. package/dist/identity/auth-service.js +44 -15
  22. package/dist/identity/index.d.ts +4 -1
  23. package/dist/identity/index.js +4 -1
  24. package/dist/identity/seed.d.ts +14 -2
  25. package/dist/identity/seed.js +197 -45
  26. package/dist/media/agent-tools.d.ts +13 -3
  27. package/dist/media/agent-tools.js +45 -10
  28. package/dist/media/content-type-sniffer.d.ts +1 -1
  29. package/dist/media/content-type-sniffer.js +52 -4
  30. package/dist/media/html-attributes.d.ts +90 -0
  31. package/dist/media/html-attributes.js +161 -0
  32. package/dist/media/image-transformer.sharp.d.ts +35 -1
  33. package/dist/media/image-transformer.sharp.js +44 -2
  34. package/dist/media/index.d.ts +2 -1
  35. package/dist/media/index.js +2 -1
  36. package/dist/media/media-service.d.ts +62 -2
  37. package/dist/media/media-service.js +230 -7
  38. package/dist/media/ports.d.ts +6 -0
  39. package/dist/media/rendition-service.d.ts +7 -1
  40. package/dist/media/rendition-service.js +10 -4
  41. package/dist/media/repo.memory.d.ts +4 -0
  42. package/dist/media/repo.memory.js +3 -0
  43. package/dist/media/tool-registrations.d.ts +12 -0
  44. package/dist/media/tool-registrations.js +20 -12
  45. package/dist/media/types.d.ts +26 -0
  46. package/dist/navigation/agent-tools.js +8 -1
  47. package/dist/navigation/index.d.ts +10 -1
  48. package/dist/navigation/index.js +10 -1
  49. package/dist/navigation/menu-service.d.ts +60 -4
  50. package/dist/navigation/menu-service.js +106 -8
  51. package/dist/settings/dictionaries/index.d.ts +3 -3
  52. package/dist/settings/dictionaries/index.js +3 -3
  53. package/dist/taxonomy/agent-tools.d.ts +5 -4
  54. package/dist/taxonomy/agent-tools.js +33 -10
  55. package/dist/taxonomy/content-lookup.d.ts +35 -0
  56. package/dist/taxonomy/content-lookup.js +28 -0
  57. package/dist/taxonomy/index.d.ts +2 -2
  58. package/dist/taxonomy/index.js +2 -2
  59. package/dist/taxonomy/repo.memory.d.ts +11 -2
  60. package/dist/taxonomy/repo.memory.js +11 -0
  61. package/dist/taxonomy/write-service.d.ts +44 -15
  62. package/dist/taxonomy/write-service.js +60 -8
  63. package/package.json +3 -2
  64. package/dist/content-types/agent-tools.d.ts.map +0 -1
  65. package/dist/content-types/agent-tools.js.map +0 -1
  66. package/dist/content-types/cleanup.d.ts.map +0 -1
  67. package/dist/content-types/cleanup.js.map +0 -1
  68. package/dist/content-types/errors.d.ts.map +0 -1
  69. package/dist/content-types/errors.js.map +0 -1
  70. package/dist/content-types/field-defs.d.ts.map +0 -1
  71. package/dist/content-types/field-defs.js.map +0 -1
  72. package/dist/content-types/index-provisioning.d.ts.map +0 -1
  73. package/dist/content-types/index-provisioning.js.map +0 -1
  74. package/dist/content-types/index.d.ts.map +0 -1
  75. package/dist/content-types/index.js.map +0 -1
  76. package/dist/content-types/lifecycle-dispatch.d.ts.map +0 -1
  77. package/dist/content-types/lifecycle-dispatch.js.map +0 -1
  78. package/dist/content-types/lifecycle.d.ts.map +0 -1
  79. package/dist/content-types/lifecycle.js.map +0 -1
  80. package/dist/content-types/list.d.ts.map +0 -1
  81. package/dist/content-types/list.js.map +0 -1
  82. package/dist/content-types/repo.memory.d.ts.map +0 -1
  83. package/dist/content-types/repo.memory.js.map +0 -1
  84. package/dist/content-types/tool-registrations.d.ts.map +0 -1
  85. package/dist/content-types/tool-registrations.js.map +0 -1
  86. package/dist/content-types/types.d.ts.map +0 -1
  87. package/dist/content-types/types.js.map +0 -1
  88. package/dist/content-types/write-service.d.ts.map +0 -1
  89. package/dist/content-types/write-service.js.map +0 -1
  90. package/dist/core/commands/change-set.d.ts.map +0 -1
  91. package/dist/core/commands/change-set.js.map +0 -1
  92. package/dist/core/commands/command.d.ts.map +0 -1
  93. package/dist/core/commands/command.js.map +0 -1
  94. package/dist/core/index.d.ts.map +0 -1
  95. package/dist/core/index.js.map +0 -1
  96. package/dist/core/ports.d.ts.map +0 -1
  97. package/dist/core/ports.js.map +0 -1
  98. package/dist/core/tools/registration-kit.d.ts.map +0 -1
  99. package/dist/core/tools/registration-kit.js.map +0 -1
  100. package/dist/entries/agent-tools.d.ts.map +0 -1
  101. package/dist/entries/agent-tools.js.map +0 -1
  102. package/dist/entries/errors.d.ts.map +0 -1
  103. package/dist/entries/errors.js.map +0 -1
  104. package/dist/entries/field-validation.d.ts.map +0 -1
  105. package/dist/entries/field-validation.js.map +0 -1
  106. package/dist/entries/index.d.ts.map +0 -1
  107. package/dist/entries/index.js.map +0 -1
  108. package/dist/entries/lifecycle-dispatch.d.ts.map +0 -1
  109. package/dist/entries/lifecycle-dispatch.js.map +0 -1
  110. package/dist/entries/list.d.ts.map +0 -1
  111. package/dist/entries/list.js.map +0 -1
  112. package/dist/entries/repo.memory.d.ts.map +0 -1
  113. package/dist/entries/repo.memory.js.map +0 -1
  114. package/dist/entries/tool-registrations.d.ts.map +0 -1
  115. package/dist/entries/tool-registrations.js.map +0 -1
  116. package/dist/entries/types.d.ts.map +0 -1
  117. package/dist/entries/types.js.map +0 -1
  118. package/dist/entries/write-service.d.ts.map +0 -1
  119. package/dist/entries/write-service.js.map +0 -1
  120. package/dist/identity/admin-crud-service.d.ts.map +0 -1
  121. package/dist/identity/admin-crud-service.js.map +0 -1
  122. package/dist/identity/agent-tool-input.d.ts.map +0 -1
  123. package/dist/identity/agent-tool-input.js.map +0 -1
  124. package/dist/identity/agent-tools.d.ts.map +0 -1
  125. package/dist/identity/agent-tools.js.map +0 -1
  126. package/dist/identity/auth-service.d.ts.map +0 -1
  127. package/dist/identity/auth-service.js.map +0 -1
  128. package/dist/identity/authorize.d.ts.map +0 -1
  129. package/dist/identity/authorize.js.map +0 -1
  130. package/dist/identity/grant-service.d.ts.map +0 -1
  131. package/dist/identity/grant-service.js.map +0 -1
  132. package/dist/identity/hasher.d.ts.map +0 -1
  133. package/dist/identity/hasher.js.map +0 -1
  134. package/dist/identity/index.d.ts.map +0 -1
  135. package/dist/identity/index.js.map +0 -1
  136. package/dist/identity/password-policy.d.ts.map +0 -1
  137. package/dist/identity/password-policy.js.map +0 -1
  138. package/dist/identity/permission-migrations.d.ts.map +0 -1
  139. package/dist/identity/permission-migrations.js.map +0 -1
  140. package/dist/identity/permissions.d.ts.map +0 -1
  141. package/dist/identity/permissions.js.map +0 -1
  142. package/dist/identity/ports.d.ts.map +0 -1
  143. package/dist/identity/ports.js.map +0 -1
  144. package/dist/identity/repo.memory.d.ts.map +0 -1
  145. package/dist/identity/repo.memory.js.map +0 -1
  146. package/dist/identity/seed.d.ts.map +0 -1
  147. package/dist/identity/seed.js.map +0 -1
  148. package/dist/identity/tool-registrations.d.ts.map +0 -1
  149. package/dist/identity/tool-registrations.js.map +0 -1
  150. package/dist/identity/types.d.ts.map +0 -1
  151. package/dist/identity/types.js.map +0 -1
  152. package/dist/identity/username.d.ts.map +0 -1
  153. package/dist/identity/username.js.map +0 -1
  154. package/dist/media/agent-tools.d.ts.map +0 -1
  155. package/dist/media/agent-tools.js.map +0 -1
  156. package/dist/media/blob-gc-lock.d.ts.map +0 -1
  157. package/dist/media/blob-gc-lock.js.map +0 -1
  158. package/dist/media/blob-gc.d.ts.map +0 -1
  159. package/dist/media/blob-gc.js.map +0 -1
  160. package/dist/media/blob-key.d.ts.map +0 -1
  161. package/dist/media/blob-key.js.map +0 -1
  162. package/dist/media/blob-store.fs.d.ts.map +0 -1
  163. package/dist/media/blob-store.fs.js.map +0 -1
  164. package/dist/media/blob-store.memory.d.ts.map +0 -1
  165. package/dist/media/blob-store.memory.js.map +0 -1
  166. package/dist/media/content-type-sniffer.d.ts.map +0 -1
  167. package/dist/media/content-type-sniffer.js.map +0 -1
  168. package/dist/media/image-transformer.d.ts.map +0 -1
  169. package/dist/media/image-transformer.js.map +0 -1
  170. package/dist/media/image-transformer.sharp.d.ts.map +0 -1
  171. package/dist/media/image-transformer.sharp.js.map +0 -1
  172. package/dist/media/index.d.ts.map +0 -1
  173. package/dist/media/index.js.map +0 -1
  174. package/dist/media/media-service.d.ts.map +0 -1
  175. package/dist/media/media-service.js.map +0 -1
  176. package/dist/media/ports.d.ts.map +0 -1
  177. package/dist/media/ports.js.map +0 -1
  178. package/dist/media/rendition-service.d.ts.map +0 -1
  179. package/dist/media/rendition-service.js.map +0 -1
  180. package/dist/media/repo.memory.d.ts.map +0 -1
  181. package/dist/media/repo.memory.js.map +0 -1
  182. package/dist/media/tool-registrations.d.ts.map +0 -1
  183. package/dist/media/tool-registrations.js.map +0 -1
  184. package/dist/media/transform-lock.d.ts.map +0 -1
  185. package/dist/media/transform-lock.js.map +0 -1
  186. package/dist/media/transform-registry.d.ts.map +0 -1
  187. package/dist/media/transform-registry.js.map +0 -1
  188. package/dist/media/transform-types.d.ts.map +0 -1
  189. package/dist/media/transform-types.js.map +0 -1
  190. package/dist/media/types.d.ts.map +0 -1
  191. package/dist/media/types.js.map +0 -1
  192. package/dist/navigation/agent-tools.d.ts.map +0 -1
  193. package/dist/navigation/agent-tools.js.map +0 -1
  194. package/dist/navigation/contracts.d.ts.map +0 -1
  195. package/dist/navigation/contracts.js.map +0 -1
  196. package/dist/navigation/index.d.ts.map +0 -1
  197. package/dist/navigation/index.js.map +0 -1
  198. package/dist/navigation/menu-service.d.ts.map +0 -1
  199. package/dist/navigation/menu-service.js.map +0 -1
  200. package/dist/navigation/ports.d.ts.map +0 -1
  201. package/dist/navigation/ports.js.map +0 -1
  202. package/dist/navigation/read-model.d.ts.map +0 -1
  203. package/dist/navigation/read-model.js.map +0 -1
  204. package/dist/navigation/reconcile.d.ts.map +0 -1
  205. package/dist/navigation/reconcile.js.map +0 -1
  206. package/dist/navigation/repo.memory.d.ts.map +0 -1
  207. package/dist/navigation/repo.memory.js.map +0 -1
  208. package/dist/navigation/resolver.d.ts.map +0 -1
  209. package/dist/navigation/resolver.js.map +0 -1
  210. package/dist/navigation/tool-registrations.d.ts.map +0 -1
  211. package/dist/navigation/tool-registrations.js.map +0 -1
  212. package/dist/navigation/types.d.ts.map +0 -1
  213. package/dist/navigation/types.js.map +0 -1
  214. package/dist/presentation/index.d.ts.map +0 -1
  215. package/dist/presentation/index.js.map +0 -1
  216. package/dist/presentation/presentation.d.ts.map +0 -1
  217. package/dist/presentation/presentation.js.map +0 -1
  218. package/dist/presentation/repo.memory.d.ts.map +0 -1
  219. package/dist/presentation/repo.memory.js.map +0 -1
  220. package/dist/server/index.d.ts.map +0 -1
  221. package/dist/server/index.js.map +0 -1
  222. package/dist/settings/agent-tools.d.ts.map +0 -1
  223. package/dist/settings/agent-tools.js.map +0 -1
  224. package/dist/settings/agent-writable-preferences.d.ts.map +0 -1
  225. package/dist/settings/agent-writable-preferences.js.map +0 -1
  226. package/dist/settings/change-feed.d.ts.map +0 -1
  227. package/dist/settings/change-feed.js.map +0 -1
  228. package/dist/settings/definitions-dispatch.d.ts.map +0 -1
  229. package/dist/settings/definitions-dispatch.js.map +0 -1
  230. package/dist/settings/dictionaries/index.d.ts.map +0 -1
  231. package/dist/settings/dictionaries/index.js.map +0 -1
  232. package/dist/settings/dictionaries/settings-dialog.ar.d.ts.map +0 -1
  233. package/dist/settings/dictionaries/settings-dialog.ar.js.map +0 -1
  234. package/dist/settings/dictionaries/settings-dialog.bn.d.ts.map +0 -1
  235. package/dist/settings/dictionaries/settings-dialog.bn.js.map +0 -1
  236. package/dist/settings/dictionaries/settings-dialog.de.d.ts.map +0 -1
  237. package/dist/settings/dictionaries/settings-dialog.de.js.map +0 -1
  238. package/dist/settings/dictionaries/settings-dialog.es.d.ts.map +0 -1
  239. package/dist/settings/dictionaries/settings-dialog.es.js.map +0 -1
  240. package/dist/settings/dictionaries/settings-dialog.fa.d.ts.map +0 -1
  241. package/dist/settings/dictionaries/settings-dialog.fa.js.map +0 -1
  242. package/dist/settings/dictionaries/settings-dialog.fr.d.ts.map +0 -1
  243. package/dist/settings/dictionaries/settings-dialog.fr.js.map +0 -1
  244. package/dist/settings/dictionaries/settings-dialog.hi.d.ts.map +0 -1
  245. package/dist/settings/dictionaries/settings-dialog.hi.js.map +0 -1
  246. package/dist/settings/dictionaries/settings-dialog.hu.d.ts.map +0 -1
  247. package/dist/settings/dictionaries/settings-dialog.hu.js.map +0 -1
  248. package/dist/settings/dictionaries/settings-dialog.id.d.ts.map +0 -1
  249. package/dist/settings/dictionaries/settings-dialog.id.js.map +0 -1
  250. package/dist/settings/dictionaries/settings-dialog.it.d.ts.map +0 -1
  251. package/dist/settings/dictionaries/settings-dialog.it.js.map +0 -1
  252. package/dist/settings/dictionaries/settings-dialog.ja.d.ts.map +0 -1
  253. package/dist/settings/dictionaries/settings-dialog.ja.js.map +0 -1
  254. package/dist/settings/dictionaries/settings-dialog.ko.d.ts.map +0 -1
  255. package/dist/settings/dictionaries/settings-dialog.ko.js.map +0 -1
  256. package/dist/settings/dictionaries/settings-dialog.pl.d.ts.map +0 -1
  257. package/dist/settings/dictionaries/settings-dialog.pl.js.map +0 -1
  258. package/dist/settings/dictionaries/settings-dialog.pt-BR.d.ts.map +0 -1
  259. package/dist/settings/dictionaries/settings-dialog.pt-BR.js.map +0 -1
  260. package/dist/settings/dictionaries/settings-dialog.ru.d.ts.map +0 -1
  261. package/dist/settings/dictionaries/settings-dialog.ru.js.map +0 -1
  262. package/dist/settings/dictionaries/settings-dialog.th.d.ts.map +0 -1
  263. package/dist/settings/dictionaries/settings-dialog.th.js.map +0 -1
  264. package/dist/settings/dictionaries/settings-dialog.tr.d.ts.map +0 -1
  265. package/dist/settings/dictionaries/settings-dialog.tr.js.map +0 -1
  266. package/dist/settings/dictionaries/settings-dialog.uk.d.ts.map +0 -1
  267. package/dist/settings/dictionaries/settings-dialog.uk.js.map +0 -1
  268. package/dist/settings/dictionaries/settings-dialog.ur.d.ts.map +0 -1
  269. package/dist/settings/dictionaries/settings-dialog.ur.js.map +0 -1
  270. package/dist/settings/dictionaries/settings-dialog.zh-CN.d.ts.map +0 -1
  271. package/dist/settings/dictionaries/settings-dialog.zh-CN.js.map +0 -1
  272. package/dist/settings/dictionaries/settings-dialog.zh-TW.d.ts.map +0 -1
  273. package/dist/settings/dictionaries/settings-dialog.zh-TW.js.map +0 -1
  274. package/dist/settings/ensure-definitions.d.ts.map +0 -1
  275. package/dist/settings/ensure-definitions.js.map +0 -1
  276. package/dist/settings/errors.d.ts.map +0 -1
  277. package/dist/settings/errors.js.map +0 -1
  278. package/dist/settings/index.d.ts.map +0 -1
  279. package/dist/settings/index.js.map +0 -1
  280. package/dist/settings/ports.d.ts.map +0 -1
  281. package/dist/settings/ports.js.map +0 -1
  282. package/dist/settings/purge-service.d.ts.map +0 -1
  283. package/dist/settings/purge-service.js.map +0 -1
  284. package/dist/settings/repo.memory.d.ts.map +0 -1
  285. package/dist/settings/repo.memory.js.map +0 -1
  286. package/dist/settings/settings.d.ts.map +0 -1
  287. package/dist/settings/settings.js.map +0 -1
  288. package/dist/settings/tool-registrations.d.ts.map +0 -1
  289. package/dist/settings/tool-registrations.js.map +0 -1
  290. package/dist/settings/types.d.ts.map +0 -1
  291. package/dist/settings/types.js.map +0 -1
  292. package/dist/settings/ui-tab-definitions.d.ts.map +0 -1
  293. package/dist/settings/ui-tab-definitions.js.map +0 -1
  294. package/dist/settings/write-service.d.ts.map +0 -1
  295. package/dist/settings/write-service.js.map +0 -1
  296. package/dist/taxonomy/agent-tools.d.ts.map +0 -1
  297. package/dist/taxonomy/agent-tools.js.map +0 -1
  298. package/dist/taxonomy/content-lookup.d.ts.map +0 -1
  299. package/dist/taxonomy/content-lookup.js.map +0 -1
  300. package/dist/taxonomy/index.d.ts.map +0 -1
  301. package/dist/taxonomy/index.js.map +0 -1
  302. package/dist/taxonomy/list.d.ts.map +0 -1
  303. package/dist/taxonomy/list.js.map +0 -1
  304. package/dist/taxonomy/merge-term.d.ts.map +0 -1
  305. package/dist/taxonomy/merge-term.js.map +0 -1
  306. package/dist/taxonomy/repo.memory.d.ts.map +0 -1
  307. package/dist/taxonomy/repo.memory.js.map +0 -1
  308. package/dist/taxonomy/validation-chain.d.ts.map +0 -1
  309. package/dist/taxonomy/validation-chain.js.map +0 -1
  310. package/dist/taxonomy/write-service.d.ts.map +0 -1
  311. package/dist/taxonomy/write-service.js.map +0 -1
  312. package/dist/workspace/agent-tools.d.ts.map +0 -1
  313. package/dist/workspace/agent-tools.js.map +0 -1
  314. package/dist/workspace/create.d.ts.map +0 -1
  315. package/dist/workspace/create.js.map +0 -1
  316. package/dist/workspace/delete.d.ts.map +0 -1
  317. package/dist/workspace/delete.js.map +0 -1
  318. package/dist/workspace/index.d.ts.map +0 -1
  319. package/dist/workspace/index.js.map +0 -1
  320. package/dist/workspace/repo.memory.d.ts.map +0 -1
  321. package/dist/workspace/repo.memory.js.map +0 -1
  322. package/dist/workspace/tool-registrations.d.ts.map +0 -1
  323. package/dist/workspace/tool-registrations.js.map +0 -1
  324. package/dist/workspace/update.d.ts.map +0 -1
  325. package/dist/workspace/update.js.map +0 -1
@@ -14,19 +14,83 @@
14
14
  * is that enum's single source of truth so the write-service's validation and the index
15
15
  * provisioner's CAST-mapping can never drift apart.
16
16
  *
17
+ * The enum is SPLIT rather than flat (2026-09-05): a kind is either INDEXABLE — it may carry
18
+ * `queryable: true` and therefore may reach `mapFieldKindToCast` — or STORAGE-ONLY, stored
19
+ * verbatim in `fieldsJson` and never indexed, sorted, or filtered on. The split is what keeps the
20
+ * CAST table's exhaustiveness compiler-enforced without forcing a CAST target to be invented for a
21
+ * kind that has none; see `index-provisioning.ts`'s `KIND_TO_CAST_LITERAL`.
22
+ *
17
23
  * Architectural role:
18
24
  * `features/content-types` domain vocabulary. No dependencies.
19
25
  */
20
- /** CIC U-001-B1 — the closed, 5-entry field-kind enum. Never extend ad hoc. */
21
- export const CONTENT_TYPE_FIELD_KINDS = ["text", "integer", "real", "boolean", "datetime"];
26
+ /**
27
+ * CIC U-001-B1 — the closed, 5-entry INDEXABLE SCALAR set. Never extend ad hoc.
28
+ *
29
+ * Unchanged since the original enum: every value that has ever reached a `CAST` literal is still
30
+ * here, in this order. Kept as its own constant so the "these five are the original, untouched
31
+ * scalars" claim is expressible in code rather than only in a comment.
32
+ */
33
+ export const CONTENT_TYPE_SCALAR_KINDS = ["text", "integer", "real", "boolean", "datetime"];
34
+ /**
35
+ * CIC U-001-B1 — kinds that MAY carry `queryable: true` and therefore MAY reach
36
+ * `mapFieldKindToCast`. Every member MUST have an entry in `index-provisioning.ts`'s
37
+ * `KIND_TO_CAST_LITERAL`; the compiler enforces this via `Record<IndexableFieldKind, string>`, so
38
+ * adding a member here fails the build until a CAST literal is supplied for it.
39
+ *
40
+ * `relation` stores a foreign entity id and shares `text`'s storage class, so its CAST target is
41
+ * the already-present literal `"TEXT"` — it adds a KEY to that table, never a new VALUE to the
42
+ * DDL alphabet.
43
+ */
44
+ export const INDEXABLE_FIELD_KINDS = [...CONTENT_TYPE_SCALAR_KINDS, "relation"];
45
+ /**
46
+ * Kinds stored verbatim inside `fieldsJson` and NEVER indexed, sorted, or filtered on. A
47
+ * storage-only kind has no CAST target — it is not that one is hard to choose, it is that a JSON
48
+ * document has no scalar storage class to cast to — so `write-service.ts` rejects
49
+ * `queryable: true` on one before any index transition can be resolved.
50
+ */
51
+ export const STORAGE_ONLY_FIELD_KINDS = ["json"];
52
+ /**
53
+ * CIC U-001-B1 — the closed field-kind enum. UNCHANGED NAME AND MEANING for every existing
54
+ * consumer: it is still the complete, closed set of legal `kind` values, and
55
+ * {@link isContentTypeFieldKind} is still the gate for it.
56
+ *
57
+ * Member order is load-bearing, not cosmetic: the five scalars must stay first and in their
58
+ * original order because this array's `join("|")` is interpolated into an operator-facing error
59
+ * message. The spread order below preserves that; the published agent-tool schema
60
+ * (`agent-tools.ts`) also spreads this constant, so both widen in lockstep with no hand-copied
61
+ * second list to drift.
62
+ */
63
+ export const CONTENT_TYPE_FIELD_KINDS = [
64
+ ...INDEXABLE_FIELD_KINDS,
65
+ ...STORAGE_ONLY_FIELD_KINDS,
66
+ ];
22
67
  /**
23
68
  * Type guard for {@link ContentTypeFieldKind}. The single gate every kind value must pass before
24
69
  * it is trusted anywhere near DDL construction (`index-provisioning.ts`) or write validation.
25
70
  *
26
- * @complexity O(1).
71
+ * Note the division of labour with {@link isIndexableFieldKind}: passing THIS gate means the value
72
+ * is a legal kind to DECLARE and STORE. It does NOT license a CAST — `mapFieldKindToCast` applies
73
+ * the narrower gate, because "storable" and "indexable" are no longer the same set.
74
+ *
75
+ * @complexity O(1) — a fixed-size array membership test.
27
76
  * @overallScore 100
28
77
  */
29
78
  export function isContentTypeFieldKind(value) {
30
79
  return typeof value === "string" && CONTENT_TYPE_FIELD_KINDS.includes(value);
31
80
  }
81
+ /**
82
+ * Type guard for {@link IndexableFieldKind} — the gate `mapFieldKindToCast` applies instead of
83
+ * {@link isContentTypeFieldKind}, and the gate `write-service.ts` applies to reject
84
+ * `queryable: true` on a storage-only kind.
85
+ *
86
+ * For all five original scalars this predicate and {@link isContentTypeFieldKind} agree exactly,
87
+ * which is what makes narrowing the CAST gate behavior-preserving for every input that could
88
+ * exist before `relation`/`json` were introduced.
89
+ *
90
+ * @complexity O(1) — a fixed-size array membership test.
91
+ * @overallScore 100
92
+ */
93
+ export function isIndexableFieldKind(value) {
94
+ return typeof value === "string" && INDEXABLE_FIELD_KINDS.includes(value);
95
+ }
32
96
  //# sourceMappingURL=types.js.map
@@ -10,8 +10,12 @@ import { type ActorIdentityInput, type ActorPrincipalKind, type ContentTypeField
10
10
  * same-transaction row + revision write (+ watermark stamp when supplied) -> index provisioning.
11
11
  *
12
12
  * `registerContentType`'s CIC U-002-B1 guard order is binding, not incidental: key grammar ->
13
- * reserved-key -> field-name grammar -> field-kind -> queryable-cap, evaluation stops at the
14
- * first failure. `updateContentTypeFields`'s CIC U-004-B1 additionally requires `expectedVersion`
13
+ * reserved-key -> field-name grammar -> field-kind -> storage-only-not-queryable -> queryable-cap,
14
+ * evaluation stops at the first failure. The storage-only check is numbered 4b rather than
15
+ * renumbering 5, because the fixed order is pinned by name in the certified suite and the existing
16
+ * five guards keep both their relative order and their identity: 4b sits strictly between the
17
+ * field-kind check and the queryable-cap check, and can only fire on a field whose kind already
18
+ * passed guard 4. `updateContentTypeFields`'s CIC U-004-B1 additionally requires `expectedVersion`
15
19
  * to be checked BEFORE the `fields_empty` floor and before any per-field guard, so a stale
16
20
  * `expectedVersion` combined with `fields: []` reports `VERSION_CONFLICT`, never
17
21
  * `VALIDATION_ERROR(fields_empty)`.
@@ -26,11 +30,22 @@ import { type ActorIdentityInput, type ActorPrincipalKind, type ContentTypeField
26
30
  * `features/content-types` domain logic. Depends only on `core/ports` and this package's own
27
31
  * `errors.ts`/`index-provisioning.ts`/`types.ts` — no adapter, no other feature.
28
32
  */
29
- /** Matches every other feature's chokepoint `AuthorizeFn` shape structurally — no shared import, kept decoupled. */
33
+ /**
34
+ * Matches every other feature's chokepoint `AuthorizeFn` shape structurally — no shared import,
35
+ * kept decoupled. `entityType`/`entityId` let `registerContentType`/`updateContentTypeFields`
36
+ * (and `lifecycle.ts`'s three transitions, which import this same type) pass
37
+ * `entityType: "content-type"` — matching every fronting HTTP route's own
38
+ * `entityType: "content-type"` pre-check. See `entries/write-service.ts`'s identical `AuthorizeFn`
39
+ * doc for why an omitted `entityType` here previously denied a content-type-scoped-only grant with
40
+ * `resource_scope_mismatch` even though the route's pre-check allowed it, and why this chokepoint —
41
+ * not the route — is the one that must stay at least as expressive.
42
+ */
30
43
  export type AuthorizeFn = (params: {
31
44
  principalId: string;
32
45
  permission: string;
33
46
  workspaceId: string;
47
+ entityType?: string | undefined;
48
+ entityId?: string | undefined;
34
49
  }) => Promise<{
35
50
  allowed: boolean;
36
51
  reason: string;
@@ -1,6 +1,6 @@
1
- import { ContentTypeNotFoundError, ForbiddenError, InvalidFieldKindError, InvalidFieldNameGrammarError, InvalidKeyGrammarError, QueryableFieldCapExceededError, ReservedContentTypeKeyError, ValidationError, VersionConflictError, } from "./errors.js";
1
+ import { ContentTypeNotFoundError, ForbiddenError, InvalidFieldKindError, InvalidFieldNameGrammarError, InvalidKeyGrammarError, QueryableFieldCapExceededError, ReservedContentTypeKeyError, StorageOnlyFieldNotQueryableError, ValidationError, VersionConflictError, } from "./errors.js";
2
2
  import { resolveFieldIndexTransition, validateIdentifierGrammar } from "./index-provisioning.js";
3
- import { isContentTypeFieldKind, } from "./types.js";
3
+ import { isContentTypeFieldKind, isIndexableFieldKind, } from "./types.js";
4
4
  /** Reserved forever for the legacy `posts` table — an operator Collection can never take these keys. */
5
5
  const RESERVED_CONTENT_TYPE_KEYS = new Set(["post", "page"]);
6
6
  /** Per-type cap on `queryable` fields (queryable-index sprawl mitigation). */
@@ -21,6 +21,7 @@ export async function registerContentType(required) {
21
21
  principalId: input.actorId,
22
22
  permission: "admin.collections.manage",
23
23
  workspaceId: input.workspaceId,
24
+ entityType: "content-type",
24
25
  });
25
26
  if (!authResult.allowed) {
26
27
  return { ok: false, error: new ForbiddenError(`principal '${input.actorId}' cannot register a content type (${authResult.reason})`) };
@@ -45,6 +46,18 @@ export async function registerContentType(required) {
45
46
  return { ok: false, error: new InvalidFieldKindError(`field '${field.name}' has kind '${field.kind}', not one of the closed field-kind enum`) };
46
47
  }
47
48
  }
49
+ // Guard 4b: a storage-only kind cannot be queryable. Placed strictly between guard 4 and guard 5
50
+ // so a bad kind still reports as a kind error, and a legal-but-unindexable kind is rejected
51
+ // BEFORE the cap count and long before `resolveFieldIndexTransition` could hand it to the index
52
+ // provisioner. This is the check that keeps a kind with no CAST target off the DDL path.
53
+ for (const field of input.fields) {
54
+ if (field.queryable && !isIndexableFieldKind(field.kind)) {
55
+ return {
56
+ ok: false,
57
+ error: new StorageOnlyFieldNotQueryableError(`field '${field.name}' has storage-only kind '${field.kind}' and cannot be queryable`),
58
+ };
59
+ }
60
+ }
48
61
  // Guard 5: queryable-field cap, submitted array alone.
49
62
  if (countQueryableFields(input.fields) > QUERYABLE_FIELD_CAP) {
50
63
  return { ok: false, error: new QueryableFieldCapExceededError(`content type '${input.key}' submits more than ${QUERYABLE_FIELD_CAP} queryable fields`) };
@@ -96,6 +109,7 @@ export async function updateContentTypeFields(required) {
96
109
  principalId: input.actorId,
97
110
  permission: "admin.collections.manage",
98
111
  workspaceId: input.workspaceId,
112
+ entityType: "content-type",
99
113
  });
100
114
  if (!authResult.allowed) {
101
115
  return { ok: false, error: new ForbiddenError(`principal '${input.actorId}' cannot update content type '${input.key}' (${authResult.reason})`) };
@@ -121,6 +135,18 @@ export async function updateContentTypeFields(required) {
121
135
  return { ok: false, error: new InvalidFieldKindError(`field '${field.name}' has kind '${field.kind}', not one of the closed field-kind enum`) };
122
136
  }
123
137
  }
138
+ // Guard 4b: a storage-only kind cannot be queryable. Placed strictly between guard 4 and guard 5
139
+ // so a bad kind still reports as a kind error, and a legal-but-unindexable kind is rejected
140
+ // BEFORE the cap count and long before `resolveFieldIndexTransition` could hand it to the index
141
+ // provisioner. This is the check that keeps a kind with no CAST target off the DDL path.
142
+ for (const field of input.fields) {
143
+ if (field.queryable && !isIndexableFieldKind(field.kind)) {
144
+ return {
145
+ ok: false,
146
+ error: new StorageOnlyFieldNotQueryableError(`field '${field.name}' has storage-only kind '${field.kind}' and cannot be queryable`),
147
+ };
148
+ }
149
+ }
124
150
  if (countQueryableFields(input.fields) > QUERYABLE_FIELD_CAP) {
125
151
  return { ok: false, error: new QueryableFieldCapExceededError(`content type '${input.key}' submits more than ${QUERYABLE_FIELD_CAP} queryable fields`) };
126
152
  }
@@ -123,8 +123,18 @@ export interface OutboxPort {
123
123
  claimPending(batchSize: number, nowIso: ISODateTime): Promise<OutboxRecord[]>;
124
124
  /** Mark a row as delivered. */
125
125
  markDelivered(id: UUID): Promise<void>;
126
- /** Mark a row as failed and set next retry time. */
127
- markFailed(id: UUID, error: string, nextAttemptAt: ISODateTime): Promise<void>;
126
+ /**
127
+ * Mark a row as failed and set its next retry time.
128
+ *
129
+ * `nextStatus` is decided by the CALLER (the retry-policy owner, e.g. a worker that tracks an
130
+ * attempt cap) — an implementation must persist exactly what it is told, not re-derive the
131
+ * decision from the row's own stored `attempts` (2026-09-06: this parameter replaced that
132
+ * adapter-side re-derivation, mirroring `WebhookDeliveryRepoPort.markFailed`'s `nextStatus`
133
+ * shape in the host). `"pending"` re-enters the retry queue at `nextAttemptAt`; `"failed"`
134
+ * is terminal — `claimPending` only ever selects `"pending"` rows, so a `"failed"` row is
135
+ * permanently excluded from retry regardless of `nextAttemptAt`.
136
+ */
137
+ markFailed(id: UUID, error: string, nextAttemptAt: ISODateTime, nextStatus: Extract<OutboxRecord["status"], "pending" | "failed">): Promise<void>;
128
138
  }
129
139
  /** Clock abstraction to make time deterministic in tests. */
130
140
  export interface ClockPort {
@@ -1,18 +1,109 @@
1
1
  function isPlainObject(value) {
2
2
  return typeof value === "object" && value !== null && !Array.isArray(value);
3
3
  }
4
+ /**
5
+ * Maximum nesting depth a `json` field's value may reach.
6
+ *
7
+ * A resource bound, not a product rule — the same role `MAX_FIELD_DEFS` plays in
8
+ * `content-types/field-defs.ts`. It is also what makes this check total: a self-referential value
9
+ * (`a.self = a`) is a legal JS object that `JSON.stringify` throws on, and it is the depth cap,
10
+ * not a visited-set, that guarantees this function terminates on one. Deliberately far above any
11
+ * plausible content payload so it can never act as a product constraint.
12
+ */
13
+ const MAX_JSON_DEPTH = 32;
14
+ /**
15
+ * Maximum number of values (scalars, arrays and objects, counted together) one `json` field may
16
+ * contain. The second half of the termination guarantee above, and the bound that stops a
17
+ * pathological wide-but-shallow payload from making validation the expensive part of a write.
18
+ */
19
+ const MAX_JSON_NODES = 10_000;
20
+ /**
21
+ * True only for objects that are structurally plain — prototype `Object.prototype` or `null`.
22
+ *
23
+ * Stricter than this module's {@link isPlainObject}, and deliberately so: a `Map`, `Set`, or class
24
+ * instance passes a `typeof === "object"` test and then `JSON.stringify`s to `{}`, silently
25
+ * discarding every value it held. A `Date` survives, but as a string — its type changes at the
26
+ * storage boundary, so a later read gets a different kind of value than the one written. Both are
27
+ * exactly the "accepted here, mangled downstream" class this check exists to reject.
28
+ */
29
+ function isStructurallyPlainObject(value) {
30
+ if (typeof value !== "object" || value === null || Array.isArray(value))
31
+ return false;
32
+ const proto = Object.getPrototypeOf(value);
33
+ return proto === Object.prototype || proto === null;
34
+ }
35
+ /**
36
+ * Verifies that a value is a JSON value — `null`, a boolean, a FINITE number, a string, an array
37
+ * of JSON values, or a plain object whose values are JSON values — within a bounded depth and node
38
+ * budget.
39
+ *
40
+ * This exists because "storage-only" means "not indexed", never "not checked". The obvious
41
+ * alternative — accepting every value for the `json` kind — is not a validator: it admits
42
+ * functions, `symbol`, `bigint`, `undefined`, `NaN`/`Infinity`, and circular references, each of
43
+ * which either throws inside `JSON.stringify` or is silently dropped/coerced by it, downstream of
44
+ * the validator whose entire job was to catch them. Rejecting them here turns a serialization
45
+ * crash (or a silent data loss) at the storage boundary into a named per-field validation error.
46
+ *
47
+ * Symbol-keyed properties are rejected rather than ignored for the same reason `field-defs.ts`
48
+ * rejects an unrecognized key instead of dropping it: `JSON.stringify` discards them, so accepting
49
+ * one would persist a value the caller did not get told was thrown away.
50
+ *
51
+ * @complexity O(n) in the number of contained values, hard-bounded by {@link MAX_JSON_NODES}, and
52
+ * O(d) stack depth bounded by {@link MAX_JSON_DEPTH}. Total on every input, including cyclic ones.
53
+ * @overallScore 100
54
+ */
55
+ function isBoundedJsonValue(value, budget, depth = 0) {
56
+ if (depth > MAX_JSON_DEPTH)
57
+ return false;
58
+ if (budget.nodes <= 0)
59
+ return false;
60
+ budget.nodes -= 1;
61
+ if (value === null)
62
+ return true;
63
+ switch (typeof value) {
64
+ case "boolean":
65
+ case "string":
66
+ return true;
67
+ case "number":
68
+ // `NaN`/`Infinity` are numbers that `JSON.stringify` silently rewrites to `null` — a value
69
+ // that reads back as a different type than the one written.
70
+ return Number.isFinite(value);
71
+ case "object":
72
+ break;
73
+ default:
74
+ // `undefined`, `function`, `symbol`, `bigint`: dropped, dropped, dropped, and throws.
75
+ return false;
76
+ }
77
+ if (Array.isArray(value)) {
78
+ return value.every((element) => isBoundedJsonValue(element, budget, depth + 1));
79
+ }
80
+ if (!isStructurallyPlainObject(value))
81
+ return false;
82
+ if (Object.getOwnPropertySymbols(value).length > 0)
83
+ return false;
84
+ return Object.values(value).every((entry) => isBoundedJsonValue(entry, budget, depth + 1));
85
+ }
4
86
  /**
5
87
  * Runtime-conformance check for one field's value against its declared kind. Loose by design
6
88
  * (e.g. `datetime` accepts any string) — kind-conformance here is about JS runtime shape, not
7
89
  * full ISO-8601/format validation, which is out of this package's scope.
8
90
  *
9
- * @complexity O(1).
91
+ * The `default: return false` arm is load-bearing: a kind added to `ContentTypeFieldKind` without
92
+ * an arm here does not fail the build, it produces a field that can be DECLARED but whose every
93
+ * value is rejected on write. Any future kind must be added here in the same change.
94
+ *
95
+ * @complexity O(1) for every scalar kind; O(n) bounded by `MAX_JSON_NODES` for `json`.
10
96
  * @overallScore 100
11
97
  */
12
98
  function conformsToKind(value, kind) {
13
99
  switch (kind) {
14
100
  case "text":
15
101
  case "datetime":
102
+ // A foreign entity id. String-shaped exactly like `text`; referential integrity is NOT checked
103
+ // here and is not checked anywhere — this function is a pure runtime-shape check with no repo
104
+ // access, and giving it one would put an I/O dependency into a module whose header commits to
105
+ // having none. The gap is real and belongs to the application layer.
106
+ case "relation":
16
107
  return typeof value === "string";
17
108
  case "integer":
18
109
  return typeof value === "number" && Number.isInteger(value);
@@ -20,6 +111,10 @@ function conformsToKind(value, kind) {
20
111
  return typeof value === "number";
21
112
  case "boolean":
22
113
  return typeof value === "boolean";
114
+ // Storage-only: no scalar shape is imposed, but the value must still be something that
115
+ // survives the `JSON.stringify` boundary intact. See `isBoundedJsonValue`.
116
+ case "json":
117
+ return isBoundedJsonValue(value, { nodes: MAX_JSON_NODES });
23
118
  default:
24
119
  return false;
25
120
  }
@@ -26,10 +26,24 @@ import type { ActorIdentityInput, EntryRecord, OwningContentType, Result } from
26
26
  * `errors.ts`/`field-validation.ts`/`types.ts` — never on `features/content-types`' write path,
27
27
  * only its read-only `OwningContentType` type shape.
28
28
  */
29
+ /**
30
+ * `entityType`/`entityId` are threaded through so `createEntry`/`resolveExistingEntryForTransition`
31
+ * below can pass `entityType: "entry"` to `deps.authorize()` — matching every fronting HTTP route's
32
+ * own `entityType: "entry"` pre-check exactly. Before this, this chokepoint's calls omitted
33
+ * `entityType` entirely, so `identity/authorize.ts`'s evaluator (`resourceType != null &&
34
+ * resourceType !== context.entityType` never matches a missing `entityType`) denied a principal
35
+ * holding ONLY an entry-scoped grant with `resource_scope_mismatch`, even though the route's own
36
+ * pre-check — and any owner/unscoped grant — allowed the identical request. This chokepoint is also
37
+ * the ONLY authorization check the agent-tool surface ever reaches (see `agent-tools.ts`'s header:
38
+ * tools call these functions directly, never through an HTTP route), so it must be at least as
39
+ * expressive as the route, not merely consistent with it.
40
+ */
29
41
  export type AuthorizeFn = (params: {
30
42
  principalId: string;
31
43
  permission: string;
32
44
  workspaceId: string;
45
+ entityType?: string | undefined;
46
+ entityId?: string | undefined;
33
47
  }) => Promise<{
34
48
  allowed: boolean;
35
49
  reason: string;
@@ -19,7 +19,7 @@ function delegationFields(input) {
19
19
  */
20
20
  export async function createEntry(required) {
21
21
  const { deps, input } = required;
22
- const authResult = await deps.authorize({ principalId: input.actorId, permission: "admin.collections.manage", workspaceId: input.workspaceId });
22
+ const authResult = await deps.authorize({ principalId: input.actorId, permission: "admin.collections.manage", workspaceId: input.workspaceId, entityType: "entry" });
23
23
  if (!authResult.allowed) {
24
24
  return { ok: false, error: new ForbiddenError(`principal '${input.actorId}' cannot create an entry (${authResult.reason})`) };
25
25
  }
@@ -82,7 +82,7 @@ export async function createEntry(required) {
82
82
  * @overallScore 100
83
83
  */
84
84
  async function resolveExistingEntryForTransition(deps, input) {
85
- const authResult = await deps.authorize({ principalId: input.actorId, permission: "admin.collections.manage", workspaceId: input.workspaceId });
85
+ const authResult = await deps.authorize({ principalId: input.actorId, permission: "admin.collections.manage", workspaceId: input.workspaceId, entityType: "entry" });
86
86
  if (!authResult.allowed) {
87
87
  return { ok: false, error: new ForbiddenError(`principal '${input.actorId}' cannot modify entry '${input.id}' (${authResult.reason})`) };
88
88
  }
@@ -97,6 +97,33 @@ export declare function updateUser(required: {
97
97
  * INV-05), then revokes every one of the target's active sessions (idempotent no-op if it has none,
98
98
  * EC-16) — a reset that left old sessions alive would not actually contain a compromised account.
99
99
  *
100
+ * Refuses a THIRD PARTY resetting the seeded owner's password (REQ-11/REQ-13's reasoning, same
101
+ * `OwnerRequiredError` `disablePrincipal` uses): `user.manage` is independently grantable and not
102
+ * owner-exclusive (`permissions.ts` — "Create/disable operator users and principals"), so without
103
+ * this check a caller holding only that one delegated permission could set a password of their own
104
+ * choosing on the seeded owner's account and log in as owner — a full takeover from a routine
105
+ * delegation. Checked before the password-policy validation so the refusal doesn't depend on the
106
+ * caller supplying a well-formed password first.
107
+ *
108
+ * Deliberately NOT unconditional the way `disablePrincipal`'s owner check is: the owner resetting
109
+ * ITS OWN password (`callerPrincipalId === principalId === seededOwnerPrincipalId`) is refused
110
+ * nothing — that self-service path is real, shipped infrastructure
111
+ * (`reset-admin-password-self-verified.ts`, driven by both the `TOVU_ADMIN_RESET_PASSWORD`
112
+ * boot-time incident-recovery hook and the `backfill-reset-admin-password.ts` CLI script), added
113
+ * specifically to let an operator recover a locked-out owner account out-of-band. Disabling has no
114
+ * legitimate self-case (an owner disabling itself only ever stands the workspace down), so
115
+ * `disablePrincipal` can refuse unconditionally; a credential rotation the owner performs on itself
116
+ * is ordinary hygiene and the documented recovery path, so only a MISMATCHED caller — the actual
117
+ * escalation shape — is refused here.
118
+ *
119
+ * Deliberately NOT INV-08-clamped (unlike `disablePrincipal`'s second guard): a reset changes a
120
+ * credential, it never changes `status`, so it can never drop the workspace's active owner-`*`
121
+ * count — INV-08's headcount guard has nothing to protect here.
122
+ *
123
+ * `input.seededOwnerPrincipalId` is the caller's job to resolve (`await deps.ownerPrincipalId` at
124
+ * the route layer, mirroring `disablePrincipal`'s own contract above) — this function takes the
125
+ * already-resolved id so it stays a plain, directly-testable function.
126
+ *
100
127
  * @complexity O(s) in the target's session count, s = active session rows to revoke — bounded by
101
128
  * the same operator-managed-roster scale assumption every other identity list call here makes.
102
129
  * @overallScore 100
@@ -108,6 +135,7 @@ export declare function resetUserPassword(required: {
108
135
  callerPrincipalId: UUID;
109
136
  principalId: UUID;
110
137
  password: string;
138
+ seededOwnerPrincipalId: UUID;
111
139
  };
112
140
  }): Promise<{
113
141
  user: UserRecord;
@@ -139,6 +139,33 @@ export async function updateUser(required) {
139
139
  * INV-05), then revokes every one of the target's active sessions (idempotent no-op if it has none,
140
140
  * EC-16) — a reset that left old sessions alive would not actually contain a compromised account.
141
141
  *
142
+ * Refuses a THIRD PARTY resetting the seeded owner's password (REQ-11/REQ-13's reasoning, same
143
+ * `OwnerRequiredError` `disablePrincipal` uses): `user.manage` is independently grantable and not
144
+ * owner-exclusive (`permissions.ts` — "Create/disable operator users and principals"), so without
145
+ * this check a caller holding only that one delegated permission could set a password of their own
146
+ * choosing on the seeded owner's account and log in as owner — a full takeover from a routine
147
+ * delegation. Checked before the password-policy validation so the refusal doesn't depend on the
148
+ * caller supplying a well-formed password first.
149
+ *
150
+ * Deliberately NOT unconditional the way `disablePrincipal`'s owner check is: the owner resetting
151
+ * ITS OWN password (`callerPrincipalId === principalId === seededOwnerPrincipalId`) is refused
152
+ * nothing — that self-service path is real, shipped infrastructure
153
+ * (`reset-admin-password-self-verified.ts`, driven by both the `TOVU_ADMIN_RESET_PASSWORD`
154
+ * boot-time incident-recovery hook and the `backfill-reset-admin-password.ts` CLI script), added
155
+ * specifically to let an operator recover a locked-out owner account out-of-band. Disabling has no
156
+ * legitimate self-case (an owner disabling itself only ever stands the workspace down), so
157
+ * `disablePrincipal` can refuse unconditionally; a credential rotation the owner performs on itself
158
+ * is ordinary hygiene and the documented recovery path, so only a MISMATCHED caller — the actual
159
+ * escalation shape — is refused here.
160
+ *
161
+ * Deliberately NOT INV-08-clamped (unlike `disablePrincipal`'s second guard): a reset changes a
162
+ * credential, it never changes `status`, so it can never drop the workspace's active owner-`*`
163
+ * count — INV-08's headcount guard has nothing to protect here.
164
+ *
165
+ * `input.seededOwnerPrincipalId` is the caller's job to resolve (`await deps.ownerPrincipalId` at
166
+ * the route layer, mirroring `disablePrincipal`'s own contract above) — this function takes the
167
+ * already-resolved id so it stays a plain, directly-testable function.
168
+ *
142
169
  * @complexity O(s) in the target's session count, s = active session rows to revoke — bounded by
143
170
  * the same operator-managed-roster scale assumption every other identity list call here makes.
144
171
  * @overallScore 100
@@ -157,6 +184,9 @@ export async function resetUserPassword(required) {
157
184
  });
158
185
  if (!target)
159
186
  throw new IdentityNotFoundError(`user '${input.principalId}' was not found`);
187
+ if (target.principalId === input.seededOwnerPrincipalId && input.callerPrincipalId !== input.seededOwnerPrincipalId) {
188
+ throw new OwnerRequiredError("the seeded owner's password can only be reset by the owner itself, never by another caller (REQ-11/REQ-13)");
189
+ }
160
190
  if (!input.password) {
161
191
  throw new IdentityValidationError("password is required");
162
192
  }
@@ -11,6 +11,13 @@ import { type PrincipalRecord, type SessionRecord } from "./types.js";
11
11
  * tokens (INV-05: the raw token is never persisted). Password verification is
12
12
  * argon2id (`hasher.ts`), never a raw comparison.
13
13
  *
14
+ * `createSessionForPrincipal` (2026-09-06) is the one session minter; `login()` calls it after
15
+ * verifying credentials, and a host that identifies a principal by some other means (e.g. a
16
+ * loopback-only single-use boot token, which has nothing to verify a password against) calls it
17
+ * directly instead of re-implementing session construction. Before this export existed, a
18
+ * downstream host had duplicated this file's private `hashToken` byte-for-byte because
19
+ * `login()` was the only minter and hard-requires a password — this closes that fork.
20
+ *
14
21
  * Architectural role:
15
22
  * Ordinary core functions (like `updatePost`/`membersWriteService`), not a
16
23
  * port — session/credential logic has one implementation.
@@ -23,6 +30,30 @@ export interface AuthServiceDeps {
23
30
  clock: ClockPort;
24
31
  idGen: IdGeneratorPort;
25
32
  }
33
+ /**
34
+ * Mint and persist a session for a principal the caller has ALREADY identified by some means
35
+ * other than this function (password verification in `login()` below, or a caller-specific proof
36
+ * such as a loopback-only single-use boot token). Factored out of `login()`'s former inline
37
+ * session-construction block (2026-09-06) so both paths share one minter — a host with no
38
+ * password to verify no longer has to hand-roll its own token hashing (and risk drifting from
39
+ * this file's private `hashToken`) just to mint a session for a principal it has already proven
40
+ * by other means.
41
+ *
42
+ * @complexity O(1) — one session write.
43
+ * @overallScore 100
44
+ */
45
+ export declare function createSessionForPrincipal(required: {
46
+ deps: AuthServiceDeps;
47
+ input: {
48
+ workspaceId: UUID;
49
+ principalId: UUID;
50
+ ip?: string | undefined;
51
+ userAgent?: string | undefined;
52
+ };
53
+ }): Promise<{
54
+ session: SessionRecord;
55
+ rawToken: string;
56
+ }>;
26
57
  /**
27
58
  * Verify `username`+`password` for an `active` principal and mint a session
28
59
  * (AC-02, REQ-06). Constant-shaped failure: a nonexistent username, a
@@ -30,7 +61,7 @@ export interface AuthServiceDeps {
30
61
  * with the same message — no user-enumeration signal.
31
62
  *
32
63
  * @complexity O(1) — one username lookup, one principal lookup, one hash
33
- * verify, one session write, one `lastLoginAt` write.
64
+ * verify, one session write (via `createSessionForPrincipal`), one `lastLoginAt` write.
34
65
  * @overallScore 100
35
66
  */
36
67
  export declare function login(required: {
@@ -12,6 +12,13 @@ import { normalizeUsername } from "./username.js";
12
12
  * tokens (INV-05: the raw token is never persisted). Password verification is
13
13
  * argon2id (`hasher.ts`), never a raw comparison.
14
14
  *
15
+ * `createSessionForPrincipal` (2026-09-06) is the one session minter; `login()` calls it after
16
+ * verifying credentials, and a host that identifies a principal by some other means (e.g. a
17
+ * loopback-only single-use boot token, which has nothing to verify a password against) calls it
18
+ * directly instead of re-implementing session construction. Before this export existed, a
19
+ * downstream host had duplicated this file's private `hashToken` byte-for-byte because
20
+ * `login()` was the only minter and hard-requires a password — this closes that fork.
21
+ *
15
22
  * Architectural role:
16
23
  * Ordinary core functions (like `updatePost`/`membersWriteService`), not a
17
24
  * port — session/credential logic has one implementation.
@@ -28,6 +35,35 @@ function newRawToken() {
28
35
  function isoPlusMs(nowIso, ms) {
29
36
  return new Date(new Date(nowIso).getTime() + ms).toISOString();
30
37
  }
38
+ /**
39
+ * Mint and persist a session for a principal the caller has ALREADY identified by some means
40
+ * other than this function (password verification in `login()` below, or a caller-specific proof
41
+ * such as a loopback-only single-use boot token). Factored out of `login()`'s former inline
42
+ * session-construction block (2026-09-06) so both paths share one minter — a host with no
43
+ * password to verify no longer has to hand-roll its own token hashing (and risk drifting from
44
+ * this file's private `hashToken`) just to mint a session for a principal it has already proven
45
+ * by other means.
46
+ *
47
+ * @complexity O(1) — one session write.
48
+ * @overallScore 100
49
+ */
50
+ export async function createSessionForPrincipal(required) {
51
+ const { deps, input } = required;
52
+ const nowIso = deps.clock.nowIso();
53
+ const rawToken = newRawToken();
54
+ const session = {
55
+ id: deps.idGen.newId(),
56
+ workspaceId: input.workspaceId,
57
+ principalId: input.principalId,
58
+ tokenHash: hashToken(rawToken),
59
+ createdAt: nowIso,
60
+ expiresAt: isoPlusMs(nowIso, SESSION_TTL_MS),
61
+ ip: input.ip,
62
+ userAgent: input.userAgent,
63
+ };
64
+ await deps.repos.sessions.save(session);
65
+ return { session, rawToken };
66
+ }
31
67
  /**
32
68
  * Verify `username`+`password` for an `active` principal and mint a session
33
69
  * (AC-02, REQ-06). Constant-shaped failure: a nonexistent username, a
@@ -35,7 +71,7 @@ function isoPlusMs(nowIso, ms) {
35
71
  * with the same message — no user-enumeration signal.
36
72
  *
37
73
  * @complexity O(1) — one username lookup, one principal lookup, one hash
38
- * verify, one session write, one `lastLoginAt` write.
74
+ * verify, one session write (via `createSessionForPrincipal`), one `lastLoginAt` write.
39
75
  * @overallScore 100
40
76
  */
41
77
  export async function login(required) {
@@ -59,20 +95,13 @@ export async function login(required) {
59
95
  if (!passwordOk) {
60
96
  throw new AuthInvalidCredentialsError("invalid username or password");
61
97
  }
62
- const nowIso = deps.clock.nowIso();
63
- const rawToken = newRawToken();
64
- const session = {
65
- id: deps.idGen.newId(),
66
- workspaceId: input.workspaceId,
67
- principalId: principal.id,
68
- tokenHash: hashToken(rawToken),
69
- createdAt: nowIso,
70
- expiresAt: isoPlusMs(nowIso, SESSION_TTL_MS),
71
- ip: input.ip,
72
- userAgent: input.userAgent,
73
- };
74
- await deps.repos.sessions.save(session);
75
- await deps.repos.users.save({ ...userRow, lastLoginAt: nowIso });
98
+ const { session, rawToken } = await createSessionForPrincipal({
99
+ deps,
100
+ input: { workspaceId: input.workspaceId, principalId: principal.id, ip: input.ip, userAgent: input.userAgent },
101
+ });
102
+ // Reuses the session's own `createdAt` rather than a second `clock.nowIso()` call — both are
103
+ // meant to be the same instant, and reading one avoids two clock reads ever disagreeing.
104
+ await deps.repos.users.save({ ...userRow, lastLoginAt: session.createdAt });
76
105
  return { principal, session, rawToken };
77
106
  }
78
107
  /**
@@ -17,7 +17,10 @@ export { InMemoryPrincipalRepo, InMemoryUserRepo, InMemorySessionRepo, InMemoryR
17
17
  * argon2 adapter belongs in `../server` once that layer exists.
18
18
  */
19
19
  export { authorize, resolveEffectivePermissions, type AuthorizeContext, type AuthorizeResult, type AuthorizeDeps, } from "./authorize.js";
20
- export { login, logout, validateSession, getEffectivePermissions, SESSION_TTL_MS, type AuthServiceDeps, } from "./auth-service.js";
20
+ export { login, logout, validateSession, getEffectivePermissions,
21
+ /** The password-free session minter `login()` itself is now built on — see its own doc for who
22
+ * else should call it directly. */
23
+ createSessionForPrincipal, SESSION_TTL_MS, type AuthServiceDeps, } from "./auth-service.js";
21
24
  export { seedIdentity, type SeedIdentityDeps, type SeedIdentityInput, type SeedIdentityResult } from "./seed.js";
22
25
  export { createUser, createRole, createPolicy, assignRole, attachPolicy,
23
26
  /** Exported so the assistant's read-only identity tools gate on the SAME caller-permission
@@ -9,7 +9,10 @@ export { InMemoryPrincipalRepo, InMemoryUserRepo, InMemorySessionRepo, InMemoryR
9
9
  * argon2 adapter belongs in `../server` once that layer exists.
10
10
  */
11
11
  export { authorize, resolveEffectivePermissions, } from "./authorize.js";
12
- export { login, logout, validateSession, getEffectivePermissions, SESSION_TTL_MS, } from "./auth-service.js";
12
+ export { login, logout, validateSession, getEffectivePermissions,
13
+ /** The password-free session minter `login()` itself is now built on — see its own doc for who
14
+ * else should call it directly. */
15
+ createSessionForPrincipal, SESSION_TTL_MS, } from "./auth-service.js";
13
16
  export { seedIdentity } from "./seed.js";
14
17
  export { createUser, createRole, createPolicy, assignRole, attachPolicy,
15
18
  /** Exported so the assistant's read-only identity tools gate on the SAME caller-permission