@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 +371 -0
- package/dist/index.d.ts +272 -235
- package/dist/index.js +1124 -94
- package/package.json +9 -8
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
|