@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.
- package/README.md +486 -0
- package/dist/access/access-resolver.d.ts +6 -0
- package/dist/access/access-resolver.d.ts.map +1 -0
- package/dist/access/access-resolver.js +12 -0
- package/dist/access/access-resolver.js.map +1 -0
- package/dist/access/access-types.d.ts +22 -0
- package/dist/access/access-types.d.ts.map +1 -0
- package/dist/access/access-types.js +2 -0
- package/dist/access/access-types.js.map +1 -0
- package/dist/access/index.d.ts +3 -0
- package/dist/access/index.d.ts.map +1 -0
- package/dist/access/index.js +2 -0
- package/dist/access/index.js.map +1 -0
- package/dist/admin/admin-routes.d.ts +9 -0
- package/dist/admin/admin-routes.d.ts.map +1 -0
- package/dist/admin/admin-routes.js +139 -0
- package/dist/admin/admin-routes.js.map +1 -0
- package/dist/admin/dashboard.d.ts +3 -0
- package/dist/admin/dashboard.d.ts.map +1 -0
- package/dist/admin/dashboard.js +9 -0
- package/dist/admin/dashboard.js.map +1 -0
- package/dist/admin/edit-view.d.ts +8 -0
- package/dist/admin/edit-view.d.ts.map +1 -0
- package/dist/admin/edit-view.js +21 -0
- package/dist/admin/edit-view.js.map +1 -0
- package/dist/admin/escape.d.ts +2 -0
- package/dist/admin/escape.d.ts.map +1 -0
- package/dist/admin/escape.js +9 -0
- package/dist/admin/escape.js.map +1 -0
- package/dist/admin/field-renderers.d.ts +3 -0
- package/dist/admin/field-renderers.d.ts.map +1 -0
- package/dist/admin/field-renderers.js +60 -0
- package/dist/admin/field-renderers.js.map +1 -0
- package/dist/admin/index.d.ts +8 -0
- package/dist/admin/index.d.ts.map +1 -0
- package/dist/admin/index.js +8 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/admin/layout.d.ts +9 -0
- package/dist/admin/layout.d.ts.map +1 -0
- package/dist/admin/layout.js +38 -0
- package/dist/admin/layout.js.map +1 -0
- package/dist/admin/list-view.d.ts +8 -0
- package/dist/admin/list-view.d.ts.map +1 -0
- package/dist/admin/list-view.js +21 -0
- package/dist/admin/list-view.js.map +1 -0
- package/dist/api/http-utils.d.ts +10 -0
- package/dist/api/http-utils.d.ts.map +1 -0
- package/dist/api/http-utils.js +29 -0
- package/dist/api/http-utils.js.map +1 -0
- package/dist/api/index.d.ts +6 -0
- package/dist/api/index.d.ts.map +1 -0
- package/dist/api/index.js +4 -0
- package/dist/api/index.js.map +1 -0
- package/dist/api/local-api.d.ts +52 -0
- package/dist/api/local-api.d.ts.map +1 -0
- package/dist/api/local-api.js +82 -0
- package/dist/api/local-api.js.map +1 -0
- package/dist/api/read-body.d.ts +6 -0
- package/dist/api/read-body.d.ts.map +1 -0
- package/dist/api/read-body.js +27 -0
- package/dist/api/read-body.js.map +1 -0
- package/dist/api/rest-api.d.ts +12 -0
- package/dist/api/rest-api.d.ts.map +1 -0
- package/dist/api/rest-api.js +100 -0
- package/dist/api/rest-api.js.map +1 -0
- package/dist/auth/auth-config.d.ts +12 -0
- package/dist/auth/auth-config.d.ts.map +1 -0
- package/dist/auth/auth-config.js +28 -0
- package/dist/auth/auth-config.js.map +1 -0
- package/dist/auth/auth-routes.d.ts +5 -0
- package/dist/auth/auth-routes.d.ts.map +1 -0
- package/dist/auth/auth-routes.js +108 -0
- package/dist/auth/auth-routes.js.map +1 -0
- package/dist/auth/cookie.d.ts +2 -0
- package/dist/auth/cookie.d.ts.map +1 -0
- package/dist/auth/cookie.js +9 -0
- package/dist/auth/cookie.js.map +1 -0
- package/dist/auth/csrf.d.ts +3 -0
- package/dist/auth/csrf.d.ts.map +1 -0
- package/dist/auth/csrf.js +14 -0
- package/dist/auth/csrf.js.map +1 -0
- package/dist/auth/index.d.ts +12 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +9 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/middleware.d.ts +9 -0
- package/dist/auth/middleware.d.ts.map +1 -0
- package/dist/auth/middleware.js +26 -0
- package/dist/auth/middleware.js.map +1 -0
- package/dist/auth/password.d.ts +5 -0
- package/dist/auth/password.d.ts.map +1 -0
- package/dist/auth/password.js +16 -0
- package/dist/auth/password.js.map +1 -0
- package/dist/auth/rate-limit.d.ts +12 -0
- package/dist/auth/rate-limit.d.ts.map +1 -0
- package/dist/auth/rate-limit.js +30 -0
- package/dist/auth/rate-limit.js.map +1 -0
- package/dist/auth/session.d.ts +9 -0
- package/dist/auth/session.d.ts.map +1 -0
- package/dist/auth/session.js +31 -0
- package/dist/auth/session.js.map +1 -0
- package/dist/config/cms-config.d.ts +26 -0
- package/dist/config/cms-config.d.ts.map +1 -0
- package/dist/config/cms-config.js +61 -0
- package/dist/config/cms-config.js.map +1 -0
- package/dist/config/index.d.ts +4 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +2 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config/plugin.d.ts +3 -0
- package/dist/config/plugin.d.ts.map +1 -0
- package/dist/config/plugin.js +2 -0
- package/dist/config/plugin.js.map +1 -0
- package/dist/db/column-map.d.ts +4 -0
- package/dist/db/column-map.d.ts.map +1 -0
- package/dist/db/column-map.js +34 -0
- package/dist/db/column-map.js.map +1 -0
- package/dist/db/index.d.ts +10 -0
- package/dist/db/index.d.ts.map +1 -0
- package/dist/db/index.js +7 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/migration-generator.d.ts +18 -0
- package/dist/db/migration-generator.d.ts.map +1 -0
- package/dist/db/migration-generator.js +126 -0
- package/dist/db/migration-generator.js.map +1 -0
- package/dist/db/query-builder.d.ts +35 -0
- package/dist/db/query-builder.d.ts.map +1 -0
- package/dist/db/query-builder.js +222 -0
- package/dist/db/query-builder.js.map +1 -0
- package/dist/db/query-types.d.ts +36 -0
- package/dist/db/query-types.d.ts.map +1 -0
- package/dist/db/query-types.js +12 -0
- package/dist/db/query-types.js.map +1 -0
- package/dist/db/safe-query.d.ts +6 -0
- package/dist/db/safe-query.d.ts.map +1 -0
- package/dist/db/safe-query.js +9 -0
- package/dist/db/safe-query.js.map +1 -0
- package/dist/db/sql-sanitize.d.ts +7 -0
- package/dist/db/sql-sanitize.d.ts.map +1 -0
- package/dist/db/sql-sanitize.js +27 -0
- package/dist/db/sql-sanitize.js.map +1 -0
- package/dist/hooks/hook-runner.d.ts +5 -0
- package/dist/hooks/hook-runner.d.ts.map +1 -0
- package/dist/hooks/hook-runner.js +18 -0
- package/dist/hooks/hook-runner.js.map +1 -0
- package/dist/hooks/hook-types.d.ts +25 -0
- package/dist/hooks/hook-types.d.ts.map +1 -0
- package/dist/hooks/hook-types.js +2 -0
- package/dist/hooks/hook-types.js.map +1 -0
- package/dist/hooks/index.d.ts +3 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/index.js +2 -0
- package/dist/hooks/index.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/media/index.d.ts +5 -0
- package/dist/media/index.d.ts.map +1 -0
- package/dist/media/index.js +4 -0
- package/dist/media/index.js.map +1 -0
- package/dist/media/media-config.d.ts +6 -0
- package/dist/media/media-config.d.ts.map +1 -0
- package/dist/media/media-config.js +37 -0
- package/dist/media/media-config.js.map +1 -0
- package/dist/media/serve-handler.d.ts +3 -0
- package/dist/media/serve-handler.d.ts.map +1 -0
- package/dist/media/serve-handler.js +47 -0
- package/dist/media/serve-handler.js.map +1 -0
- package/dist/media/upload-handler.d.ts +9 -0
- package/dist/media/upload-handler.d.ts.map +1 -0
- package/dist/media/upload-handler.js +63 -0
- package/dist/media/upload-handler.js.map +1 -0
- package/dist/schema/collection.d.ts +19 -0
- package/dist/schema/collection.d.ts.map +1 -0
- package/dist/schema/collection.js +7 -0
- package/dist/schema/collection.js.map +1 -0
- package/dist/schema/field-types.d.ts +73 -0
- package/dist/schema/field-types.d.ts.map +1 -0
- package/dist/schema/field-types.js +13 -0
- package/dist/schema/field-types.js.map +1 -0
- package/dist/schema/fields.d.ts +14 -0
- package/dist/schema/fields.d.ts.map +1 -0
- package/dist/schema/fields.js +33 -0
- package/dist/schema/fields.js.map +1 -0
- package/dist/schema/global.d.ts +8 -0
- package/dist/schema/global.d.ts.map +1 -0
- package/dist/schema/global.js +4 -0
- package/dist/schema/global.js.map +1 -0
- package/dist/schema/index.d.ts +13 -0
- package/dist/schema/index.d.ts.map +1 -0
- package/dist/schema/index.js +7 -0
- package/dist/schema/index.js.map +1 -0
- package/dist/schema/infer.d.ts +20 -0
- package/dist/schema/infer.d.ts.map +1 -0
- package/dist/schema/infer.js +2 -0
- package/dist/schema/infer.js.map +1 -0
- package/dist/schema/registry.d.ts +19 -0
- package/dist/schema/registry.d.ts.map +1 -0
- package/dist/schema/registry.js +65 -0
- package/dist/schema/registry.js.map +1 -0
- package/dist/schema/types.d.ts +15 -0
- package/dist/schema/types.d.ts.map +1 -0
- package/dist/schema/types.js +10 -0
- package/dist/schema/types.js.map +1 -0
- package/dist/validation/index.d.ts +3 -0
- package/dist/validation/index.d.ts.map +1 -0
- package/dist/validation/index.js +3 -0
- package/dist/validation/index.js.map +1 -0
- package/dist/validation/validators.d.ts +3 -0
- package/dist/validation/validators.d.ts.map +1 -0
- package/dist/validation/validators.js +9 -0
- package/dist/validation/validators.js.map +1 -0
- package/dist/validation/zod-generator.d.ts +5 -0
- package/dist/validation/zod-generator.d.ts.map +1 -0
- package/dist/validation/zod-generator.js +83 -0
- package/dist/validation/zod-generator.js.map +1 -0
- 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 @@
|
|
|
1
|
+
{"version":3,"file":"access-types.js","sourceRoot":"","sources":["../../src/access/access-types.ts"],"names":[],"mappings":""}
|
|
@@ -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 @@
|
|
|
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"}
|