@vertz/server 0.2.0 → 0.2.1

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 ADDED
@@ -0,0 +1,371 @@
1
+ # @vertz/server
2
+
3
+ Type-safe REST APIs from entity definitions. Define your schema, set access rules, get production-ready CRUD endpoints.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ bun add @vertz/server @vertz/db
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```typescript
14
+ import { d } from '@vertz/db';
15
+ import { createServer, entity } from '@vertz/server';
16
+
17
+ // 1. Define schema
18
+ const todosTable = d.table('todos', {
19
+ id: d.uuid().primary(),
20
+ title: d.text(),
21
+ completed: d.boolean().default(false),
22
+ createdAt: d.timestamp().default('now').readOnly(),
23
+ updatedAt: d.timestamp().autoUpdate(),
24
+ });
25
+
26
+ const todosModel = d.model(todosTable);
27
+
28
+ // 2. Define entity with access control
29
+ const todos = entity('todos', {
30
+ model: todosModel,
31
+ access: {
32
+ list: () => true,
33
+ get: () => true,
34
+ create: (ctx) => ctx.authenticated(),
35
+ update: (ctx) => ctx.authenticated(),
36
+ delete: (ctx) => ctx.role('admin'),
37
+ },
38
+ });
39
+
40
+ // 3. Start server — CRUD routes auto-generated
41
+ const app = createServer({
42
+ entities: [todos],
43
+ });
44
+
45
+ app.listen(3000);
46
+ ```
47
+
48
+ This generates 5 REST endpoints:
49
+
50
+ | Method | Path | Operation |
51
+ |---|---|---|
52
+ | `GET` | `/api/todos` | List all |
53
+ | `GET` | `/api/todos/:id` | Get by ID |
54
+ | `POST` | `/api/todos` | Create |
55
+ | `PATCH` | `/api/todos/:id` | Update |
56
+ | `DELETE` | `/api/todos/:id` | Delete |
57
+
58
+ ## Entities
59
+
60
+ ### Defining Entities
61
+
62
+ An entity connects a `@vertz/db` model to the server with access control, hooks, and custom actions:
63
+
64
+ ```typescript
65
+ import { entity } from '@vertz/server';
66
+
67
+ const users = entity('users', {
68
+ model: usersModel,
69
+ access: { /* ... */ },
70
+ before: { /* ... */ },
71
+ after: { /* ... */ },
72
+ actions: { /* ... */ },
73
+ });
74
+ ```
75
+
76
+ ### Access Control
77
+
78
+ Operations without an access rule are **denied by default**. Set `false` to explicitly disable (returns 405), or provide a function:
79
+
80
+ ```typescript
81
+ const posts = entity('posts', {
82
+ model: postsModel,
83
+ access: {
84
+ // Public read
85
+ list: () => true,
86
+ get: () => true,
87
+
88
+ // Authenticated write
89
+ create: (ctx) => ctx.authenticated(),
90
+
91
+ // Owner-only update (row-level access)
92
+ update: (ctx, row) => row.authorId === ctx.userId,
93
+
94
+ // Admin-only delete
95
+ delete: (ctx) => ctx.role('admin'),
96
+ },
97
+ });
98
+ ```
99
+
100
+ ### EntityContext
101
+
102
+ Access rules, hooks, and actions receive an `EntityContext`:
103
+
104
+ ```typescript
105
+ interface EntityContext {
106
+ userId: string | null;
107
+ authenticated(): boolean; // true if userId !== null
108
+ tenant(): boolean; // true if tenantId !== null
109
+ role(...roles: string[]): boolean; // check user roles
110
+
111
+ entity: EntityOperations; // typed CRUD on the current entity
112
+ entities: Record<string, EntityOperations>; // CRUD on any entity
113
+ }
114
+ ```
115
+
116
+ ### Before Hooks
117
+
118
+ Transform data before it reaches the database:
119
+
120
+ ```typescript
121
+ const posts = entity('posts', {
122
+ model: postsModel,
123
+ access: { create: (ctx) => ctx.authenticated() },
124
+ before: {
125
+ create: (data, ctx) => ({
126
+ ...data,
127
+ authorId: ctx.userId, // inject current user
128
+ slug: slugify(data.title),
129
+ }),
130
+ update: (data, ctx) => ({
131
+ ...data,
132
+ // strip fields users shouldn't control
133
+ }),
134
+ },
135
+ });
136
+ ```
137
+
138
+ ### After Hooks
139
+
140
+ Run side effects after database writes. After hooks receive already-stripped data (hidden fields removed) and their return value is ignored:
141
+
142
+ ```typescript
143
+ const users = entity('users', {
144
+ model: usersModel,
145
+ access: { create: () => true, delete: (ctx) => ctx.role('admin') },
146
+ after: {
147
+ create: async (result, ctx) => {
148
+ await sendWelcomeEmail(result.email);
149
+ },
150
+ update: async (prev, next, ctx) => {
151
+ await logChange(prev, next);
152
+ },
153
+ delete: async (row, ctx) => {
154
+ await cleanupUserData(row.id);
155
+ },
156
+ },
157
+ });
158
+ ```
159
+
160
+ ### Custom Actions
161
+
162
+ Add business logic beyond CRUD:
163
+
164
+ ```typescript
165
+ import { s } from '@vertz/schema';
166
+
167
+ const orders = entity('orders', {
168
+ model: ordersModel,
169
+ access: {
170
+ list: () => true,
171
+ cancel: (ctx, row) => row.customerId === ctx.userId,
172
+ },
173
+ actions: {
174
+ cancel: {
175
+ input: s.object({ reason: s.string().min(1) }),
176
+ output: s.object({ cancelled: s.boolean() }),
177
+ handler: async (input, ctx, row) => {
178
+ await ctx.entity.update(row.id, { status: 'cancelled' });
179
+ await notifyCustomer(row.customerId, input.reason);
180
+ return { cancelled: true };
181
+ },
182
+ },
183
+ },
184
+ });
185
+ ```
186
+
187
+ Custom actions create a `POST /api/orders/:id/cancel` endpoint.
188
+
189
+ ### Field Stripping
190
+
191
+ Column annotations from `@vertz/db` are automatically enforced:
192
+
193
+ - **`.hidden()`** fields are never sent in API responses
194
+ - **`.readOnly()`** fields are stripped from create/update request bodies
195
+ - **`.primary()`** fields are automatically read-only
196
+
197
+ ```typescript
198
+ const users = d.table('users', {
199
+ id: d.uuid().primary(),
200
+ name: d.text(),
201
+ passwordHash: d.text().hidden(), // never in responses
202
+ createdAt: d.timestamp().default('now').readOnly(), // can't be set by client
203
+ });
204
+ ```
205
+
206
+ ## Server Configuration
207
+
208
+ ```typescript
209
+ const app = createServer({
210
+ entities: [users, posts, comments],
211
+ apiPrefix: '/api', // API prefix (default: '/api')
212
+ });
213
+ ```
214
+
215
+ ## Authentication
216
+
217
+ ```typescript
218
+ import { createAuth } from '@vertz/server';
219
+
220
+ const auth = createAuth({
221
+ session: {
222
+ strategy: 'jwt',
223
+ ttl: '7d',
224
+ cookie: { name: 'session', httpOnly: true, secure: true },
225
+ },
226
+ jwtSecret: process.env.AUTH_SECRET!,
227
+ emailPassword: {
228
+ enabled: true,
229
+ password: { minLength: 8, requireUppercase: true },
230
+ rateLimit: { window: '15m', maxAttempts: 5 },
231
+ },
232
+ });
233
+ ```
234
+
235
+ ### Auth Configuration
236
+
237
+ | Option | Type | Default | Description |
238
+ |--------|------|---------|-------------|
239
+ | `session` | `SessionConfig` | *required* | Session strategy, TTL, and cookie settings |
240
+ | `jwtSecret` | `string` | — | JWT signing secret. **Required in production** |
241
+ | `jwtAlgorithm` | `'HS256' \| 'HS384' \| 'HS512' \| 'RS256'` | `'HS256'` | JWT signing algorithm |
242
+ | `emailPassword` | `EmailPasswordConfig` | — | Password requirements and rate limiting |
243
+ | `claims` | `(user: AuthUser) => Record<string, unknown>` | — | Custom JWT claims |
244
+ | `isProduction` | `boolean` | auto-detected | Override production mode detection |
245
+
246
+ ### Production Mode
247
+
248
+ By default, `createAuth` auto-detects production mode from `NODE_ENV`. In production mode:
249
+ - `jwtSecret` is **required** (throws if missing)
250
+ - CSRF validation is enforced on state-changing requests
251
+
252
+ On edge runtimes where `process` is unavailable (Cloudflare Workers, Deno Deploy), the default is **production** (secure-by-default). Pass `isProduction: false` explicitly for development:
253
+
254
+ ```typescript
255
+ // Edge runtime — defaults to production (secure)
256
+ const auth = createAuth({
257
+ session: { strategy: 'jwt', ttl: '7d' },
258
+ jwtSecret: env.AUTH_SECRET, // required — no fallback
259
+ });
260
+
261
+ // Edge runtime in development — opt in explicitly
262
+ const auth = createAuth({
263
+ session: { strategy: 'jwt', ttl: '7d' },
264
+ isProduction: false, // uses insecure default secret
265
+ });
266
+ ```
267
+
268
+ Auth generates endpoints:
269
+
270
+ | Method | Path | Operation |
271
+ |---|---|---|
272
+ | `POST` | `/api/auth/signup` | Create account |
273
+ | `POST` | `/api/auth/signin` | Authenticate |
274
+ | `POST` | `/api/auth/signout` | Invalidate session |
275
+ | `GET` | `/api/auth/session` | Get current session |
276
+ | `POST` | `/api/auth/refresh` | Refresh JWT |
277
+
278
+ Server-side API:
279
+
280
+ ```typescript
281
+ const result = await auth.api.signUp({
282
+ email: 'alice@example.com',
283
+ password: 'secure-password',
284
+ });
285
+
286
+ if (result.ok) {
287
+ console.log(result.data); // session
288
+ }
289
+ ```
290
+
291
+ ## Error Handling
292
+
293
+ Entity routes return consistent error responses:
294
+
295
+ ```json
296
+ {
297
+ "error": {
298
+ "code": "NOT_FOUND",
299
+ "message": "Resource not found"
300
+ }
301
+ }
302
+ ```
303
+
304
+ | Status | Code | When |
305
+ |---|---|---|
306
+ | 400 | `BAD_REQUEST` | Invalid request |
307
+ | 401 | `UNAUTHORIZED` | Not authenticated |
308
+ | 403 | `FORBIDDEN` | Access denied |
309
+ | 404 | `NOT_FOUND` | Resource not found |
310
+ | 405 | `METHOD_NOT_ALLOWED` | Operation disabled (`access: false`) |
311
+ | 409 | `CONFLICT` | Unique/FK constraint violation |
312
+ | 422 | `VALIDATION_ERROR` | Schema validation failed |
313
+ | 500 | `INTERNAL_ERROR` | Unexpected error |
314
+
315
+ Validation errors include details:
316
+
317
+ ```json
318
+ {
319
+ "error": {
320
+ "code": "VALIDATION_ERROR",
321
+ "message": "Validation failed",
322
+ "details": [
323
+ { "path": ["title"], "message": "Required" }
324
+ ]
325
+ }
326
+ }
327
+ ```
328
+
329
+ ## Full Example
330
+
331
+ ```typescript
332
+ import { d } from '@vertz/db';
333
+ import { createServer, entity } from '@vertz/server';
334
+ import { s } from '@vertz/schema';
335
+
336
+ // Schema
337
+ const todosTable = d.table('todos', {
338
+ id: d.uuid().primary(),
339
+ title: d.text(),
340
+ completed: d.boolean().default(false),
341
+ createdAt: d.timestamp().default('now').readOnly(),
342
+ updatedAt: d.timestamp().autoUpdate(),
343
+ });
344
+
345
+ const todosModel = d.model(todosTable);
346
+
347
+ // Entity
348
+ const todos = entity('todos', {
349
+ model: todosModel,
350
+ access: {
351
+ list: () => true,
352
+ get: () => true,
353
+ create: () => true,
354
+ update: () => true,
355
+ delete: () => true,
356
+ },
357
+ });
358
+
359
+ // Server
360
+ const app = createServer({
361
+ entities: [todos],
362
+ });
363
+
364
+ app.listen(3000).then((handle) => {
365
+ console.log(`API running at http://localhost:${handle.port}/api`);
366
+ });
367
+ ```
368
+
369
+ ## License
370
+
371
+ MIT