@valencets/cms 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 (218) hide show
  1. package/README.md +486 -0
  2. package/dist/access/access-resolver.d.ts +6 -0
  3. package/dist/access/access-resolver.d.ts.map +1 -0
  4. package/dist/access/access-resolver.js +12 -0
  5. package/dist/access/access-resolver.js.map +1 -0
  6. package/dist/access/access-types.d.ts +22 -0
  7. package/dist/access/access-types.d.ts.map +1 -0
  8. package/dist/access/access-types.js +2 -0
  9. package/dist/access/access-types.js.map +1 -0
  10. package/dist/access/index.d.ts +3 -0
  11. package/dist/access/index.d.ts.map +1 -0
  12. package/dist/access/index.js +2 -0
  13. package/dist/access/index.js.map +1 -0
  14. package/dist/admin/admin-routes.d.ts +9 -0
  15. package/dist/admin/admin-routes.d.ts.map +1 -0
  16. package/dist/admin/admin-routes.js +139 -0
  17. package/dist/admin/admin-routes.js.map +1 -0
  18. package/dist/admin/dashboard.d.ts +3 -0
  19. package/dist/admin/dashboard.d.ts.map +1 -0
  20. package/dist/admin/dashboard.js +9 -0
  21. package/dist/admin/dashboard.js.map +1 -0
  22. package/dist/admin/edit-view.d.ts +8 -0
  23. package/dist/admin/edit-view.d.ts.map +1 -0
  24. package/dist/admin/edit-view.js +21 -0
  25. package/dist/admin/edit-view.js.map +1 -0
  26. package/dist/admin/escape.d.ts +2 -0
  27. package/dist/admin/escape.d.ts.map +1 -0
  28. package/dist/admin/escape.js +9 -0
  29. package/dist/admin/escape.js.map +1 -0
  30. package/dist/admin/field-renderers.d.ts +3 -0
  31. package/dist/admin/field-renderers.d.ts.map +1 -0
  32. package/dist/admin/field-renderers.js +60 -0
  33. package/dist/admin/field-renderers.js.map +1 -0
  34. package/dist/admin/index.d.ts +8 -0
  35. package/dist/admin/index.d.ts.map +1 -0
  36. package/dist/admin/index.js +8 -0
  37. package/dist/admin/index.js.map +1 -0
  38. package/dist/admin/layout.d.ts +9 -0
  39. package/dist/admin/layout.d.ts.map +1 -0
  40. package/dist/admin/layout.js +38 -0
  41. package/dist/admin/layout.js.map +1 -0
  42. package/dist/admin/list-view.d.ts +8 -0
  43. package/dist/admin/list-view.d.ts.map +1 -0
  44. package/dist/admin/list-view.js +21 -0
  45. package/dist/admin/list-view.js.map +1 -0
  46. package/dist/api/http-utils.d.ts +10 -0
  47. package/dist/api/http-utils.d.ts.map +1 -0
  48. package/dist/api/http-utils.js +29 -0
  49. package/dist/api/http-utils.js.map +1 -0
  50. package/dist/api/index.d.ts +6 -0
  51. package/dist/api/index.d.ts.map +1 -0
  52. package/dist/api/index.js +4 -0
  53. package/dist/api/index.js.map +1 -0
  54. package/dist/api/local-api.d.ts +52 -0
  55. package/dist/api/local-api.d.ts.map +1 -0
  56. package/dist/api/local-api.js +82 -0
  57. package/dist/api/local-api.js.map +1 -0
  58. package/dist/api/read-body.d.ts +6 -0
  59. package/dist/api/read-body.d.ts.map +1 -0
  60. package/dist/api/read-body.js +27 -0
  61. package/dist/api/read-body.js.map +1 -0
  62. package/dist/api/rest-api.d.ts +12 -0
  63. package/dist/api/rest-api.d.ts.map +1 -0
  64. package/dist/api/rest-api.js +100 -0
  65. package/dist/api/rest-api.js.map +1 -0
  66. package/dist/auth/auth-config.d.ts +12 -0
  67. package/dist/auth/auth-config.d.ts.map +1 -0
  68. package/dist/auth/auth-config.js +28 -0
  69. package/dist/auth/auth-config.js.map +1 -0
  70. package/dist/auth/auth-routes.d.ts +5 -0
  71. package/dist/auth/auth-routes.d.ts.map +1 -0
  72. package/dist/auth/auth-routes.js +108 -0
  73. package/dist/auth/auth-routes.js.map +1 -0
  74. package/dist/auth/cookie.d.ts +2 -0
  75. package/dist/auth/cookie.d.ts.map +1 -0
  76. package/dist/auth/cookie.js +9 -0
  77. package/dist/auth/cookie.js.map +1 -0
  78. package/dist/auth/csrf.d.ts +3 -0
  79. package/dist/auth/csrf.d.ts.map +1 -0
  80. package/dist/auth/csrf.js +14 -0
  81. package/dist/auth/csrf.js.map +1 -0
  82. package/dist/auth/index.d.ts +12 -0
  83. package/dist/auth/index.d.ts.map +1 -0
  84. package/dist/auth/index.js +9 -0
  85. package/dist/auth/index.js.map +1 -0
  86. package/dist/auth/middleware.d.ts +9 -0
  87. package/dist/auth/middleware.d.ts.map +1 -0
  88. package/dist/auth/middleware.js +26 -0
  89. package/dist/auth/middleware.js.map +1 -0
  90. package/dist/auth/password.d.ts +5 -0
  91. package/dist/auth/password.d.ts.map +1 -0
  92. package/dist/auth/password.js +16 -0
  93. package/dist/auth/password.js.map +1 -0
  94. package/dist/auth/rate-limit.d.ts +12 -0
  95. package/dist/auth/rate-limit.d.ts.map +1 -0
  96. package/dist/auth/rate-limit.js +30 -0
  97. package/dist/auth/rate-limit.js.map +1 -0
  98. package/dist/auth/session.d.ts +9 -0
  99. package/dist/auth/session.d.ts.map +1 -0
  100. package/dist/auth/session.js +31 -0
  101. package/dist/auth/session.js.map +1 -0
  102. package/dist/config/cms-config.d.ts +26 -0
  103. package/dist/config/cms-config.d.ts.map +1 -0
  104. package/dist/config/cms-config.js +61 -0
  105. package/dist/config/cms-config.js.map +1 -0
  106. package/dist/config/index.d.ts +4 -0
  107. package/dist/config/index.d.ts.map +1 -0
  108. package/dist/config/index.js +2 -0
  109. package/dist/config/index.js.map +1 -0
  110. package/dist/config/plugin.d.ts +3 -0
  111. package/dist/config/plugin.d.ts.map +1 -0
  112. package/dist/config/plugin.js +2 -0
  113. package/dist/config/plugin.js.map +1 -0
  114. package/dist/db/column-map.d.ts +4 -0
  115. package/dist/db/column-map.d.ts.map +1 -0
  116. package/dist/db/column-map.js +34 -0
  117. package/dist/db/column-map.js.map +1 -0
  118. package/dist/db/index.d.ts +10 -0
  119. package/dist/db/index.d.ts.map +1 -0
  120. package/dist/db/index.js +7 -0
  121. package/dist/db/index.js.map +1 -0
  122. package/dist/db/migration-generator.d.ts +18 -0
  123. package/dist/db/migration-generator.d.ts.map +1 -0
  124. package/dist/db/migration-generator.js +126 -0
  125. package/dist/db/migration-generator.js.map +1 -0
  126. package/dist/db/query-builder.d.ts +35 -0
  127. package/dist/db/query-builder.d.ts.map +1 -0
  128. package/dist/db/query-builder.js +222 -0
  129. package/dist/db/query-builder.js.map +1 -0
  130. package/dist/db/query-types.d.ts +36 -0
  131. package/dist/db/query-types.d.ts.map +1 -0
  132. package/dist/db/query-types.js +12 -0
  133. package/dist/db/query-types.js.map +1 -0
  134. package/dist/db/safe-query.d.ts +6 -0
  135. package/dist/db/safe-query.d.ts.map +1 -0
  136. package/dist/db/safe-query.js +9 -0
  137. package/dist/db/safe-query.js.map +1 -0
  138. package/dist/db/sql-sanitize.d.ts +7 -0
  139. package/dist/db/sql-sanitize.d.ts.map +1 -0
  140. package/dist/db/sql-sanitize.js +27 -0
  141. package/dist/db/sql-sanitize.js.map +1 -0
  142. package/dist/hooks/hook-runner.d.ts +5 -0
  143. package/dist/hooks/hook-runner.d.ts.map +1 -0
  144. package/dist/hooks/hook-runner.js +18 -0
  145. package/dist/hooks/hook-runner.js.map +1 -0
  146. package/dist/hooks/hook-types.d.ts +25 -0
  147. package/dist/hooks/hook-types.d.ts.map +1 -0
  148. package/dist/hooks/hook-types.js +2 -0
  149. package/dist/hooks/hook-types.js.map +1 -0
  150. package/dist/hooks/index.d.ts +3 -0
  151. package/dist/hooks/index.d.ts.map +1 -0
  152. package/dist/hooks/index.js +2 -0
  153. package/dist/hooks/index.js.map +1 -0
  154. package/dist/index.d.ts +21 -0
  155. package/dist/index.d.ts.map +1 -0
  156. package/dist/index.js +13 -0
  157. package/dist/index.js.map +1 -0
  158. package/dist/media/index.d.ts +5 -0
  159. package/dist/media/index.d.ts.map +1 -0
  160. package/dist/media/index.js +4 -0
  161. package/dist/media/index.js.map +1 -0
  162. package/dist/media/media-config.d.ts +6 -0
  163. package/dist/media/media-config.d.ts.map +1 -0
  164. package/dist/media/media-config.js +37 -0
  165. package/dist/media/media-config.js.map +1 -0
  166. package/dist/media/serve-handler.d.ts +3 -0
  167. package/dist/media/serve-handler.d.ts.map +1 -0
  168. package/dist/media/serve-handler.js +47 -0
  169. package/dist/media/serve-handler.js.map +1 -0
  170. package/dist/media/upload-handler.d.ts +9 -0
  171. package/dist/media/upload-handler.d.ts.map +1 -0
  172. package/dist/media/upload-handler.js +63 -0
  173. package/dist/media/upload-handler.js.map +1 -0
  174. package/dist/schema/collection.d.ts +19 -0
  175. package/dist/schema/collection.d.ts.map +1 -0
  176. package/dist/schema/collection.js +7 -0
  177. package/dist/schema/collection.js.map +1 -0
  178. package/dist/schema/field-types.d.ts +73 -0
  179. package/dist/schema/field-types.d.ts.map +1 -0
  180. package/dist/schema/field-types.js +13 -0
  181. package/dist/schema/field-types.js.map +1 -0
  182. package/dist/schema/fields.d.ts +14 -0
  183. package/dist/schema/fields.d.ts.map +1 -0
  184. package/dist/schema/fields.js +33 -0
  185. package/dist/schema/fields.js.map +1 -0
  186. package/dist/schema/global.d.ts +8 -0
  187. package/dist/schema/global.d.ts.map +1 -0
  188. package/dist/schema/global.js +4 -0
  189. package/dist/schema/global.js.map +1 -0
  190. package/dist/schema/index.d.ts +13 -0
  191. package/dist/schema/index.d.ts.map +1 -0
  192. package/dist/schema/index.js +7 -0
  193. package/dist/schema/index.js.map +1 -0
  194. package/dist/schema/infer.d.ts +20 -0
  195. package/dist/schema/infer.d.ts.map +1 -0
  196. package/dist/schema/infer.js +2 -0
  197. package/dist/schema/infer.js.map +1 -0
  198. package/dist/schema/registry.d.ts +19 -0
  199. package/dist/schema/registry.d.ts.map +1 -0
  200. package/dist/schema/registry.js +65 -0
  201. package/dist/schema/registry.js.map +1 -0
  202. package/dist/schema/types.d.ts +15 -0
  203. package/dist/schema/types.d.ts.map +1 -0
  204. package/dist/schema/types.js +10 -0
  205. package/dist/schema/types.js.map +1 -0
  206. package/dist/validation/index.d.ts +3 -0
  207. package/dist/validation/index.d.ts.map +1 -0
  208. package/dist/validation/index.js +3 -0
  209. package/dist/validation/index.js.map +1 -0
  210. package/dist/validation/validators.d.ts +3 -0
  211. package/dist/validation/validators.d.ts.map +1 -0
  212. package/dist/validation/validators.js +9 -0
  213. package/dist/validation/validators.js.map +1 -0
  214. package/dist/validation/zod-generator.d.ts +5 -0
  215. package/dist/validation/zod-generator.d.ts.map +1 -0
  216. package/dist/validation/zod-generator.js +83 -0
  217. package/dist/validation/zod-generator.js.map +1 -0
  218. package/package.json +39 -0
package/README.md ADDED
@@ -0,0 +1,486 @@
1
+ # @valencets/cms
2
+
3
+ > See the [CMS Guide on the wiki](https://github.com/valencets/valence/wiki/Packages:-Cms) for the latest documentation.
4
+
5
+ Schema-driven CMS for Valence. Define a schema, get a database, admin interface, REST API, validation, auth, and media uploads out of the box.
6
+
7
+ ## Quick Start
8
+
9
+ ```typescript
10
+ import { buildCms, collection, field, global } from '@valencets/cms'
11
+ import { createPool } from '@valencets/db'
12
+
13
+ const pool = createPool({ host: 'localhost', port: 5432, database: 'myapp', username: 'postgres', password: '', max: 10, idle_timeout: 20, connect_timeout: 10 })
14
+
15
+ const result = buildCms({
16
+ db: pool,
17
+ secret: process.env.CMS_SECRET,
18
+ uploadDir: './uploads',
19
+ collections: [
20
+ collection({
21
+ slug: 'posts',
22
+ labels: { singular: 'Post', plural: 'Posts' },
23
+ fields: [
24
+ field.text({ name: 'title', required: true }),
25
+ field.slug({ name: 'slug', required: true, unique: true }),
26
+ field.textarea({ name: 'body' }),
27
+ field.boolean({ name: 'published' }),
28
+ field.select({
29
+ name: 'status',
30
+ options: [
31
+ { label: 'Draft', value: 'draft' },
32
+ { label: 'Published', value: 'published' }
33
+ ]
34
+ }),
35
+ field.date({ name: 'publishedAt' }),
36
+ field.relation({ name: 'author', relationTo: 'users' }),
37
+ field.group({
38
+ name: 'seo',
39
+ fields: [
40
+ field.text({ name: 'metaTitle' }),
41
+ field.textarea({ name: 'metaDescription' })
42
+ ]
43
+ })
44
+ ]
45
+ }),
46
+
47
+ collection({
48
+ slug: 'users',
49
+ auth: true,
50
+ fields: [
51
+ field.text({ name: 'name', required: true })
52
+ ]
53
+ }),
54
+
55
+ collection({
56
+ slug: 'media',
57
+ upload: true,
58
+ fields: [
59
+ field.text({ name: 'alt' })
60
+ ]
61
+ })
62
+ ],
63
+ globals: [
64
+ global({
65
+ slug: 'site-settings',
66
+ label: 'Site Settings',
67
+ fields: [
68
+ field.text({ name: 'siteName', required: true }),
69
+ field.textarea({ name: 'siteDescription' })
70
+ ]
71
+ })
72
+ ]
73
+ })
74
+
75
+ if (result.isErr()) {
76
+ console.error('CMS init failed:', result.error.message)
77
+ process.exit(1)
78
+ }
79
+
80
+ const cms = result.value
81
+ // cms.api — Local API (find, create, update, delete)
82
+ // cms.restRoutes — Auto-generated REST endpoints
83
+ // cms.adminRoutes — Server-rendered admin panel
84
+ // cms.collections — Collection registry
85
+ // cms.globals — Global registry
86
+ ```
87
+
88
+ ## Schema
89
+
90
+ ### Collections
91
+
92
+ Collections are database-backed document types. Each collection gets a PostgreSQL table, Zod validation, REST endpoints, and admin UI.
93
+
94
+ ```typescript
95
+ import { collection, field } from '@valencets/cms'
96
+
97
+ const pages = collection({
98
+ slug: 'pages', // Table name, URL path segment
99
+ labels: { singular: 'Page', plural: 'Pages' }, // Admin UI labels (optional)
100
+ timestamps: true, // created_at, updated_at (default true)
101
+ auth: false, // Enable auth (auto-adds email, password_hash)
102
+ upload: false, // Enable media uploads (auto-adds file fields)
103
+ fields: [/* ... */]
104
+ })
105
+ ```
106
+
107
+ ### Field Types (v0.1)
108
+
109
+ | Factory | PG Type | Zod Type | Options |
110
+ |---------|---------|----------|---------|
111
+ | `field.text()` | `TEXT` | `z.string()` | `minLength`, `maxLength` |
112
+ | `field.textarea()` | `TEXT` | `z.string()` | `minLength`, `maxLength` |
113
+ | `field.number()` | `INTEGER`/`NUMERIC` | `z.number()` | `min`, `max`, `hasDecimals` |
114
+ | `field.boolean()` | `BOOLEAN` | `z.boolean()` | — |
115
+ | `field.select()` | `TEXT` + CHECK | `z.enum()` | `options: [{label, value}]`, `hasMany` |
116
+ | `field.date()` | `TIMESTAMPTZ` | `z.string()` | — |
117
+ | `field.slug()` | `TEXT` | `z.string()` | `slugFrom` (auto-generate from field) |
118
+ | `field.media()` | `UUID` FK | `z.string().uuid()` | `relationTo` |
119
+ | `field.relation()` | `UUID` FK | `z.string().uuid()` | `relationTo`, `hasMany` |
120
+ | `field.group()` | `JSONB` | nested object | `fields: [...]` |
121
+
122
+ All fields share base options: `name` (required), `required`, `unique`, `index`, `defaultValue`, `hidden`, `localized`, `label`.
123
+
124
+ ### Globals
125
+
126
+ Singleton documents (site settings, navigation, footer). One row per global.
127
+
128
+ ```typescript
129
+ import { global, field } from '@valencets/cms'
130
+
131
+ const siteSettings = global({
132
+ slug: 'site-settings',
133
+ label: 'Site Settings',
134
+ fields: [
135
+ field.text({ name: 'siteName', required: true }),
136
+ field.textarea({ name: 'siteDescription' })
137
+ ]
138
+ })
139
+ ```
140
+
141
+ ### Type Inference
142
+
143
+ Extract TypeScript types from field definitions at the type level:
144
+
145
+ ```typescript
146
+ import type { InferFieldsType } from '@valencets/cms'
147
+
148
+ const postFields = [
149
+ field.text({ name: 'title' }),
150
+ field.number({ name: 'order' }),
151
+ field.boolean({ name: 'active' })
152
+ ] as const
153
+
154
+ type Post = InferFieldsType<typeof postFields>
155
+ // { title: string, order: number, active: boolean }
156
+ ```
157
+
158
+ ## Local API
159
+
160
+ Direct function calls for server-side operations. All methods return `ResultAsync<T, CmsError>`.
161
+
162
+ ```typescript
163
+ const cms = buildCms(config)._unsafeUnwrap()
164
+ const api = cms.api
165
+
166
+ // Find all
167
+ const posts = await api.find({ collection: 'posts' })
168
+
169
+ // Find with filters
170
+ const published = await api.find({
171
+ collection: 'posts',
172
+ where: { published: true },
173
+ limit: 10
174
+ })
175
+
176
+ // Find by ID
177
+ const post = await api.findByID({ collection: 'posts', id: 'uuid-here' })
178
+
179
+ // Create
180
+ const newPost = await api.create({
181
+ collection: 'posts',
182
+ data: { title: 'Hello', slug: 'hello' }
183
+ })
184
+
185
+ // Update
186
+ const updated = await api.update({
187
+ collection: 'posts',
188
+ id: 'uuid-here',
189
+ data: { title: 'Updated' }
190
+ })
191
+
192
+ // Delete (soft delete)
193
+ const deleted = await api.delete({ collection: 'posts', id: 'uuid-here' })
194
+
195
+ // Count
196
+ const count = await api.count({ collection: 'posts' })
197
+
198
+ // Globals
199
+ const settings = await api.findGlobal({ slug: 'site-settings' })
200
+ const updatedSettings = await api.updateGlobal({
201
+ slug: 'site-settings',
202
+ data: { siteName: 'New Name' }
203
+ })
204
+ ```
205
+
206
+ ## REST API
207
+
208
+ Auto-generated JSON endpoints per collection. Requires `Content-Type: application/json` on mutating requests.
209
+
210
+ | Method | Path | Description |
211
+ |--------|------|-------------|
212
+ | `GET` | `/api/:collection` | List documents |
213
+ | `POST` | `/api/:collection` | Create document (Zod validated) |
214
+ | `GET` | `/api/:collection/:id` | Get document by ID |
215
+ | `PATCH` | `/api/:collection/:id` | Update document (Zod validated) |
216
+ | `DELETE` | `/api/:collection/:id` | Soft delete document |
217
+
218
+ ### Auth Endpoints (when `auth: true` collection exists)
219
+
220
+ | Method | Path | Description |
221
+ |--------|------|-------------|
222
+ | `POST` | `/api/users/login` | Login (email + password, Zod validated) |
223
+ | `POST` | `/api/users/logout` | Logout (clears session cookie) |
224
+ | `GET` | `/api/users/me` | Current user (requires session) |
225
+
226
+ ### Media Endpoints (when `uploadDir` configured with `upload: true` collection)
227
+
228
+ | Method | Path | Description |
229
+ |--------|------|-------------|
230
+ | `POST` | `/media/upload` | Upload file (raw body, `X-Filename` header) |
231
+ | `GET` | `/media/:filename` | Serve uploaded file |
232
+
233
+ ## Admin Panel
234
+
235
+ Server-rendered HTML admin interface. Auto-generated from registered collections.
236
+
237
+ - `/admin` — Dashboard with collection cards
238
+ - `/admin/:collection` — Document list with table
239
+ - `/admin/:collection/new` — Create form (CSRF protected, Zod validated)
240
+ - `/admin/:collection/:id/edit` — Edit form
241
+
242
+ ### Auth Protection
243
+
244
+ ```typescript
245
+ const routes = createAdminRoutes(pool, collections, { requireAuth: true })
246
+ // All admin routes return 401 without valid session cookie
247
+ ```
248
+
249
+ ## Query Builder
250
+
251
+ Chainable query API wrapping PostgreSQL's parameterized queries via `sql.unsafe()`.
252
+
253
+ ```typescript
254
+ const qb = createQueryBuilder(pool, registry)
255
+
256
+ // Chain operations
257
+ const result = await qb.query('posts')
258
+ .where('published', 'equals', true)
259
+ .where('status', 'not_equals', 'draft')
260
+ .orderBy('created_at', 'desc')
261
+ .limit(10)
262
+ .all()
263
+
264
+ // Pagination
265
+ const page = await qb.query('posts')
266
+ .where('published', true)
267
+ .page(1, 10)
268
+ // Returns { docs, totalDocs, page, totalPages, limit, hasNextPage, hasPrevPage }
269
+
270
+ // Shorthand where (defaults to equals)
271
+ qb.query('posts').where('slug', 'hello-world').first()
272
+
273
+ // Include soft-deleted rows
274
+ qb.query('posts').withDeleted().all()
275
+ ```
276
+
277
+ ### Where Operators
278
+
279
+ `equals`, `not_equals`, `greater_than`, `less_than`, `greater_than_or_equal`, `less_than_or_equal`, `like`, `in`, `exists`
280
+
281
+ ## Migrations
282
+
283
+ Generate PostgreSQL DDL from collection schemas.
284
+
285
+ ```typescript
286
+ import { generateCreateTableSql, generateAlterTableSql, generateCreateTable } from '@valencets/cms'
287
+
288
+ // Generate CREATE TABLE
289
+ const sql = generateCreateTableSql(postsCollection)
290
+ // CREATE TABLE IF NOT EXISTS "posts" (
291
+ // "id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
292
+ // "title" TEXT NOT NULL,
293
+ // "slug" TEXT NOT NULL UNIQUE,
294
+ // ...
295
+ // "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
296
+ // "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
297
+ // "deleted_at" TIMESTAMPTZ
298
+ // );
299
+
300
+ // Generate ALTER TABLE for schema changes
301
+ const alterSql = generateAlterTableSql('posts', {
302
+ added: [field.text({ name: 'subtitle' })],
303
+ removed: ['old_field'],
304
+ changed: [field.number({ name: 'price', hasDecimals: true })]
305
+ })
306
+
307
+ // Generate migration file (with name + up/down)
308
+ const migration = generateCreateTable(postsCollection)
309
+ // Returns Result<{ name: '1234_create_posts', up: 'CREATE TABLE...', down: 'DROP TABLE...' }, CmsError>
310
+ ```
311
+
312
+ ## Auth
313
+
314
+ Argon2id password hashing. Session-based authentication with secure cookie flags.
315
+
316
+ ```typescript
317
+ import { hashPassword, verifyPassword, createSession, validateSession } from '@valencets/cms'
318
+
319
+ // Hash password
320
+ const hash = await hashPassword('my-password')
321
+ // Returns ResultAsync<string, CmsError>
322
+
323
+ // Verify password
324
+ const valid = await verifyPassword('my-password', hash)
325
+ // Returns ResultAsync<boolean, CmsError>
326
+
327
+ // Session management
328
+ const sessionId = await createSession(userId, pool)
329
+ const userId = await validateSession(sessionId, pool)
330
+ await destroySession(sessionId, pool)
331
+ ```
332
+
333
+ ### Session Cookies
334
+
335
+ - `HttpOnly` — not accessible to JavaScript
336
+ - `SameSite=Strict` — not sent on cross-site requests
337
+ - `Secure` — HTTPS only
338
+ - `Max-Age=7200` — 2 hour expiration
339
+
340
+ ### Rate Limiting
341
+
342
+ Login endpoint rate-limited to 5 attempts per email per 15 minutes. Returns `429 Too Many Requests` when exceeded.
343
+
344
+ ### CSRF Protection
345
+
346
+ Admin form POST handlers are protected with one-time CSRF tokens:
347
+ - GET renders a hidden `_csrf` field in the form
348
+ - POST validates the token (constant-time comparison)
349
+ - Tokens expire after 1 hour
350
+ - Each token is consumed on use
351
+
352
+ ## Access Control
353
+
354
+ Per-collection, per-operation access functions returning `boolean` or `WhereClause` for row-level security.
355
+
356
+ ```typescript
357
+ import { collection, field } from '@valencets/cms'
358
+ import type { CollectionAccess } from '@valencets/cms'
359
+
360
+ const access: CollectionAccess = {
361
+ create: ({ req }) => req?.headers['x-role'] === 'admin',
362
+ read: () => ({ and: [{ field: 'published', operator: 'equals', value: true }] }),
363
+ update: ({ req }) => req?.headers['x-role'] === 'admin',
364
+ delete: ({ req }) => req?.headers['x-role'] === 'admin'
365
+ }
366
+ ```
367
+
368
+ ## Hooks
369
+
370
+ Lifecycle hooks for collections. Hooks execute sequentially. Return data to transform it through the chain, or `undefined` to pass through.
371
+
372
+ ```typescript
373
+ import type { CollectionHooks } from '@valencets/cms'
374
+
375
+ const hooks: CollectionHooks = {
376
+ beforeValidate: [(args) => ({ ...args.data, slug: slugify(args.data.title) })],
377
+ beforeChange: [],
378
+ afterChange: [(args) => { notifyWebhook(args.data); return undefined }],
379
+ beforeRead: [],
380
+ afterRead: [],
381
+ beforeDelete: [],
382
+ afterDelete: []
383
+ }
384
+ ```
385
+
386
+ ## Plugins
387
+
388
+ Pure functional config transformers. Plugins receive the CMS config and return a modified version.
389
+
390
+ ```typescript
391
+ import type { Plugin } from '@valencets/cms'
392
+
393
+ const seoPlugin: Plugin = (config) => ({
394
+ ...config,
395
+ collections: config.collections.map(col => ({
396
+ ...col,
397
+ fields: [
398
+ ...col.fields,
399
+ field.group({
400
+ name: 'seo',
401
+ fields: [
402
+ field.text({ name: 'metaTitle' }),
403
+ field.textarea({ name: 'metaDescription' })
404
+ ]
405
+ })
406
+ ]
407
+ }))
408
+ })
409
+
410
+ const cms = buildCms({
411
+ ...config,
412
+ plugins: [seoPlugin]
413
+ })
414
+ ```
415
+
416
+ ## Validation
417
+
418
+ Zod schemas generated from field definitions. `.safeParse()` only — never `.parse()`.
419
+
420
+ ```typescript
421
+ import { generateZodSchema, generatePartialSchema } from '@valencets/cms'
422
+
423
+ const schema = generateZodSchema(postsCollection.fields)
424
+ const result = schema.safeParse({ title: 'Hello', slug: 'hello' })
425
+
426
+ if (!result.success) {
427
+ console.log(result.error.issues)
428
+ }
429
+
430
+ // Partial schema for updates (all fields optional, types still validated)
431
+ const partialSchema = generatePartialSchema(postsCollection.fields)
432
+ partialSchema.safeParse({ title: 'Updated' }) // OK, slug not required
433
+ ```
434
+
435
+ ## Error Handling
436
+
437
+ All operations return `Result<T, CmsError>` or `ResultAsync<T, CmsError>`. No exceptions.
438
+
439
+ ```typescript
440
+ import { CmsErrorCode } from '@valencets/cms'
441
+
442
+ const result = await api.findByID({ collection: 'posts', id: 'missing' })
443
+ result.match(
444
+ (doc) => console.log('Found:', doc),
445
+ (err) => {
446
+ // err.code is one of:
447
+ // NOT_FOUND, INVALID_INPUT, VALIDATION_FAILED,
448
+ // DUPLICATE_SLUG, UNAUTHORIZED, FORBIDDEN, INTERNAL
449
+ console.error(err.code, err.message)
450
+ }
451
+ )
452
+ ```
453
+
454
+ ## Security
455
+
456
+ - **SQL injection** — All queries use parameterized values via `sql.unsafe()`. Identifiers validated against `[a-zA-Z][a-zA-Z0-9_-]*` regex and checked against collection schema before interpolation.
457
+ - **XSS** — All HTML output uses `escapeHtml()` (escapes `& < > " '`). No raw interpolation.
458
+ - **CSRF** — One-time tokens with constant-time validation and 1-hour TTL on admin forms. REST API requires `Content-Type: application/json`.
459
+ - **Path traversal** — Media filenames validated against `[a-zA-Z0-9][a-zA-Z0-9._-]*`, resolved paths checked with `startsWith(uploadDir)`.
460
+ - **Auth** — Argon2id hashing, `HttpOnly; SameSite=Strict; Secure` cookies, rate limiting on login.
461
+ - **Input validation** — Zod schemas enforced on REST POST/PATCH and admin form POST.
462
+
463
+ ## Module Map
464
+
465
+ ```
466
+ packages/cms/src/
467
+ ├── schema/ # collection(), global(), field.*, registry, type inference
468
+ ├── validation/ # Zod schema generator, slug/email validators
469
+ ├── db/ # Query builder, migration generator, SQL sanitization
470
+ ├── access/ # Access control types and resolver
471
+ ├── hooks/ # Lifecycle hook types and runner
472
+ ├── auth/ # Password hashing, sessions, middleware, CSRF, rate limiting
473
+ ├── api/ # Local API, REST API, HTTP utilities
474
+ ├── admin/ # Server-rendered admin panel (layout, views, field renderers)
475
+ ├── media/ # Upload/serve handlers, MIME detection
476
+ ├── config/ # buildCms() entry point, plugin system
477
+ └── index.ts # Package barrel export
478
+ ```
479
+
480
+ ## Testing
481
+
482
+ 270 tests across 34 test files.
483
+
484
+ ```bash
485
+ pnpm --filter=cms test
486
+ ```
@@ -0,0 +1,6 @@
1
+ import { ResultAsync } from 'neverthrow';
2
+ import type { CmsError } from '../schema/types.js';
3
+ import type { WhereClause } from '../db/query-types.js';
4
+ import type { AccessControlFunction, AccessArgs } from './access-types.js';
5
+ export declare function resolveAccess(accessFn: AccessControlFunction | undefined, args: AccessArgs): ResultAsync<boolean | WhereClause, CmsError>;
6
+ //# sourceMappingURL=access-resolver.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"access-resolver.d.ts","sourceRoot":"","sources":["../../src/access/access-resolver.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AAExC,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAA;AAClD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAA;AACvD,OAAO,KAAK,EAAE,qBAAqB,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAA;AAE1E,wBAAgB,aAAa,CAC3B,QAAQ,EAAE,qBAAqB,GAAG,SAAS,EAC3C,IAAI,EAAE,UAAU,GACf,WAAW,CAAC,OAAO,GAAG,WAAW,EAAE,QAAQ,CAAC,CAY9C"}
@@ -0,0 +1,12 @@
1
+ import { ResultAsync } from 'neverthrow';
2
+ import { CmsErrorCode } from '../schema/types.js';
3
+ export function resolveAccess(accessFn, args) {
4
+ if (accessFn === undefined) {
5
+ return ResultAsync.fromSafePromise(Promise.resolve(true));
6
+ }
7
+ return ResultAsync.fromPromise(Promise.resolve().then(() => accessFn(args)), (e) => ({
8
+ code: CmsErrorCode.INTERNAL,
9
+ message: e instanceof Error ? e.message : 'Access check failed'
10
+ }));
11
+ }
12
+ //# sourceMappingURL=access-resolver.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"access-resolver.js","sourceRoot":"","sources":["../../src/access/access-resolver.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AACxC,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAA;AAKjD,MAAM,UAAU,aAAa,CAC3B,QAA2C,EAC3C,IAAgB;IAEhB,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;QAC3B,OAAO,WAAW,CAAC,eAAe,CAAC,OAAO,CAAC,OAAO,CAAC,IAA6B,CAAC,CAAC,CAAA;IACpF,CAAC;IAED,OAAO,WAAW,CAAC,WAAW,CAC5B,OAAO,CAAC,OAAO,EAAE,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,EAC5C,CAAC,CAAU,EAAY,EAAE,CAAC,CAAC;QACzB,IAAI,EAAE,YAAY,CAAC,QAAQ;QAC3B,OAAO,EAAE,CAAC,YAAY,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,qBAAqB;KAChE,CAAC,CACH,CAAA;AACH,CAAC"}
@@ -0,0 +1,22 @@
1
+ import type { WhereClause } from '../db/query-types.js';
2
+ export interface AccessArgs {
3
+ readonly req?: {
4
+ readonly headers: Record<string, string>;
5
+ } | undefined;
6
+ readonly id?: string | undefined;
7
+ readonly data?: Record<string, string | number | boolean | null> | undefined;
8
+ }
9
+ export type AccessControlFunction = (args: AccessArgs) => boolean | WhereClause;
10
+ export interface CollectionAccess {
11
+ readonly create?: AccessControlFunction | undefined;
12
+ readonly read?: AccessControlFunction | undefined;
13
+ readonly update?: AccessControlFunction | undefined;
14
+ readonly delete?: AccessControlFunction | undefined;
15
+ readonly admin?: AccessControlFunction | undefined;
16
+ }
17
+ export interface FieldAccess {
18
+ readonly create?: AccessControlFunction | undefined;
19
+ readonly read?: AccessControlFunction | undefined;
20
+ readonly update?: AccessControlFunction | undefined;
21
+ }
22
+ //# sourceMappingURL=access-types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"access-types.d.ts","sourceRoot":"","sources":["../../src/access/access-types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAA;AAEvD,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,GAAG,CAAC,EAAE;QAAE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;KAAE,GAAG,SAAS,CAAA;IACvE,QAAQ,CAAC,EAAE,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IAChC,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,IAAI,CAAC,GAAG,SAAS,CAAA;CAC7E;AAED,MAAM,MAAM,qBAAqB,GAAG,CAAC,IAAI,EAAE,UAAU,KAAK,OAAO,GAAG,WAAW,CAAA;AAE/E,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,MAAM,CAAC,EAAE,qBAAqB,GAAG,SAAS,CAAA;IACnD,QAAQ,CAAC,IAAI,CAAC,EAAE,qBAAqB,GAAG,SAAS,CAAA;IACjD,QAAQ,CAAC,MAAM,CAAC,EAAE,qBAAqB,GAAG,SAAS,CAAA;IACnD,QAAQ,CAAC,MAAM,CAAC,EAAE,qBAAqB,GAAG,SAAS,CAAA;IACnD,QAAQ,CAAC,KAAK,CAAC,EAAE,qBAAqB,GAAG,SAAS,CAAA;CACnD;AAED,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,MAAM,CAAC,EAAE,qBAAqB,GAAG,SAAS,CAAA;IACnD,QAAQ,CAAC,IAAI,CAAC,EAAE,qBAAqB,GAAG,SAAS,CAAA;IACjD,QAAQ,CAAC,MAAM,CAAC,EAAE,qBAAqB,GAAG,SAAS,CAAA;CACpD"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=access-types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"access-types.js","sourceRoot":"","sources":["../../src/access/access-types.ts"],"names":[],"mappings":""}
@@ -0,0 +1,3 @@
1
+ export { resolveAccess } from './access-resolver.js';
2
+ export type { AccessControlFunction, AccessArgs, CollectionAccess, FieldAccess } from './access-types.js';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/access/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAA;AACpD,YAAY,EAAE,qBAAqB,EAAE,UAAU,EAAE,gBAAgB,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA"}
@@ -0,0 +1,2 @@
1
+ export { resolveAccess } from './access-resolver.js';
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/access/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAA"}
@@ -0,0 +1,9 @@
1
+ import type { DbPool } from '@valencets/db';
2
+ import type { CollectionRegistry } from '../schema/registry.js';
3
+ import type { RestRouteEntry } from '../api/rest-api.js';
4
+ interface AdminOptions {
5
+ readonly requireAuth?: boolean | undefined;
6
+ }
7
+ export declare function createAdminRoutes(pool: DbPool, collections: CollectionRegistry, options?: AdminOptions): Map<string, RestRouteEntry>;
8
+ export {};
9
+ //# sourceMappingURL=admin-routes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"admin-routes.d.ts","sourceRoot":"","sources":["../../src/admin/admin-routes.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,eAAe,CAAA;AAC3C,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAA;AAC/D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAA;AAiBxD,UAAU,YAAY;IACpB,QAAQ,CAAC,WAAW,CAAC,EAAE,OAAO,GAAG,SAAS,CAAA;CAC3C;AAwCD,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,MAAM,EACZ,WAAW,EAAE,kBAAkB,EAC/B,OAAO,GAAE,YAAiB,GACzB,GAAG,CAAC,MAAM,EAAE,cAAc,CAAC,CAkG7B"}