webspresso 0.0.99 → 0.0.101
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/.agents/ADMIN.md +47 -4
- package/.agents/AGENTS.md +4 -2
- package/.agents/AUTH.md +309 -0
- package/.agents/CLI.md +128 -0
- package/.agents/EMAIL.md +84 -2
- package/.agents/ERRORS.md +59 -0
- package/.agents/EVENTS.md +179 -0
- package/.agents/ORM.md +149 -0
- package/.agents/PLUGINS.md +44 -7
- package/.agents/QUEUE.md +18 -10
- package/.agents/ROUTING.md +97 -1
- package/.agents/STREAMING.md +35 -0
- package/AGENTS.md +2 -0
- package/README.md +145 -93
- package/bin/commands/add-docker.js +467 -0
- package/bin/commands/db-makemigrations.js +83 -0
- package/bin/commands/deploy-check.js +339 -0
- package/bin/commands/list.js +96 -0
- package/bin/commands/make-command.js +93 -0
- package/bin/commands/make-event.js +80 -0
- package/bin/commands/make-factory.js +72 -0
- package/bin/commands/make-mail.js +93 -0
- package/bin/commands/make-policy.js +106 -0
- package/bin/commands/run.js +195 -0
- package/bin/webspresso.js +21 -0
- package/core/auth/bouncer.js +196 -0
- package/core/auth/index.js +73 -1
- package/core/auth/magic-link.js +170 -0
- package/core/auth/manager.js +343 -3
- package/core/auth/middleware.js +45 -6
- package/core/auth/session-manager.js +255 -0
- package/core/auth/sessions.js +351 -0
- package/core/auth/tokens.js +1 -0
- package/core/auth/totp.js +4 -1
- package/core/auth/user-agent.js +161 -0
- package/core/commands/define-command.js +48 -0
- package/core/commands/index.js +18 -0
- package/core/commands/registry.js +156 -0
- package/core/commands/runner.js +76 -0
- package/core/compression/compressible.js +1 -1
- package/core/compression/middleware.js +35 -251
- package/core/content/service.js +8 -2
- package/core/events/discovery.js +142 -0
- package/core/events/dispatcher.js +552 -0
- package/core/events/index.js +23 -0
- package/core/events/signal.js +92 -0
- package/core/forms/flash.js +234 -0
- package/core/forms/form-builder.js +214 -0
- package/core/forms/index.js +16 -0
- package/core/mail/index.js +23 -0
- package/core/mail/mailable.js +250 -0
- package/core/mail/mailer.js +184 -0
- package/core/mail/transports.js +238 -0
- package/core/orm/factory.js +250 -0
- package/core/orm/index.js +8 -1
- package/core/orm/migrations/diff.js +185 -0
- package/core/orm/migrations/index.js +22 -4
- package/core/orm/model.js +55 -1
- package/core/orm/repository.js +283 -13
- package/core/orm/scopes.js +3 -2
- package/core/orm/types.js +6 -1
- package/core/queue/cron.js +252 -39
- package/core/queue/manager.js +20 -2
- package/core/queue/scheduler.js +115 -59
- package/core/ssr/stream.js +62 -2
- package/index.d.ts +540 -14
- package/index.js +44 -0
- package/package.json +11 -5
- package/plugins/admin-panel/api.js +509 -3
- package/plugins/admin-panel/index.js +17 -0
- package/plugins/admin-panel/modules/admin-users.js +1 -1
- package/plugins/audit-log/admin-component.js +54 -54
- package/plugins/audit-log/index.js +2 -2
- package/plugins/content/client/inline-edit.js +2 -46
- package/plugins/content/models/content-entry.js +4 -3
- package/plugins/content/models/content-type.js +4 -3
- package/plugins/cors.js +15 -4
- package/plugins/email/auth-bridge.js +66 -0
- package/plugins/email/bundled-templates.js +15 -0
- package/plugins/email/index.js +48 -0
- package/plugins/email/preview-handler.js +555 -0
- package/plugins/email/service.js +23 -12
- package/plugins/email/templates/auth-magic-link.mjml +13 -0
- package/plugins/email/transport.js +30 -11
- package/plugins/file-manager/admin-component.js +49 -49
- package/plugins/file-manager/index.js +4 -4
- package/plugins/index.js +2 -0
- package/plugins/redirect/index.js +1 -1
- package/plugins/webhooks/admin-component.js +608 -0
- package/plugins/webhooks/api-handlers.js +237 -0
- package/plugins/webhooks/crypto.js +144 -0
- package/plugins/webhooks/dispatcher.js +708 -0
- package/plugins/webhooks/index.js +263 -0
- package/plugins/webhooks/matcher.js +113 -0
- package/plugins/webhooks/migration-template.js +75 -0
- package/plugins/webhooks/models.js +98 -0
- package/plugins/webhooks/sender.js +176 -0
- package/src/api/api-loader.js +1 -1
- package/src/app-context.js +48 -6
- package/src/bundle/index.js +2 -1
- package/src/dev-reload/index.js +9 -1
- package/src/discovery/scan-routes.js +10 -1
- package/src/file-router.js +173 -4
- package/src/helpers.js +150 -1
- package/src/njk-frontmatter.js +3 -0
- package/src/pages/page-loader.js +1 -1
- package/src/routing/cascading.js +120 -4
- package/src/server.js +180 -26
package/.agents/ERRORS.md
CHANGED
|
@@ -199,3 +199,62 @@ In server logs, 500 errors are automatically highlighted with visual banners pin
|
|
|
199
199
|
Phase: handler
|
|
200
200
|
```
|
|
201
201
|
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## 6. Directory-Level Error Boundaries (`_error.njk` & `_error.js`)
|
|
205
|
+
|
|
206
|
+
In addition to API JSON errors and the top-level 500 error page, Webspresso provides **Directory-Level Error Boundaries** for SSR pages (similar to Next.js `error.js` or Nuxt `error.vue`):
|
|
207
|
+
|
|
208
|
+
```
|
|
209
|
+
pages/
|
|
210
|
+
├── _layout.njk # Root site layout
|
|
211
|
+
├── _error.njk # Global site error fallback
|
|
212
|
+
└── dashboard/
|
|
213
|
+
├── _layout.njk # Dashboard layout (sidebar, header)
|
|
214
|
+
├── _error.njk # Dashboard-scoped error boundary
|
|
215
|
+
├── _error.js # Optional companion loader
|
|
216
|
+
├── billing/
|
|
217
|
+
│ └── invoices.njk # If load() fails here...
|
|
218
|
+
└── settings.njk
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### 6.1 Isolated Error Containment
|
|
222
|
+
When an unhandled error is thrown in a page route's `load()`, template rendering, or lifecycle hooks:
|
|
223
|
+
1. Webspresso searches up the directory hierarchy (innermost leaf to root `pages/`) for the nearest `_error.njk`.
|
|
224
|
+
2. The error boundary is rendered **inside** the ancestor cascading layouts (`_layout.njk`), ensuring that headers, sidebars, and navigation remain visible while displaying the isolated error state.
|
|
225
|
+
3. The HTTP response status is preserved from `error.status || error.statusCode || 500` (e.g. throwing `NotFoundError` sets HTTP 404).
|
|
226
|
+
|
|
227
|
+
### 6.2 Companion Loader (`_error.js`)
|
|
228
|
+
An optional companion `_error.js` can prefetch support data, report errors to monitoring services, or enrich the context:
|
|
229
|
+
|
|
230
|
+
```javascript
|
|
231
|
+
// pages/dashboard/_error.js
|
|
232
|
+
module.exports = {
|
|
233
|
+
load: async ({ error, req, res, ctx, db }) => {
|
|
234
|
+
return {
|
|
235
|
+
supportEmail: 'support@example.com',
|
|
236
|
+
incidentId: req.id,
|
|
237
|
+
};
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### 6.3 Template Context Variables
|
|
243
|
+
Inside `_error.njk`, the following variables are injected:
|
|
244
|
+
- `error`: `{ message, status, statusCode, code, name, details, fields, stack }` (stack is only exposed when `isDev === true`).
|
|
245
|
+
- `route`: `{ routePath, file }`.
|
|
246
|
+
- `req`: `{ path, query, params, method, url }`.
|
|
247
|
+
- `fsy`: Template helper suite.
|
|
248
|
+
- Plus any data returned by companion `_error.js` loader.
|
|
249
|
+
|
|
250
|
+
### 6.4 Layout Opt-Out
|
|
251
|
+
To render a full-page custom error view without the parent layout, set `layout: false` in `_error.js` or frontmatter:
|
|
252
|
+
```yaml
|
|
253
|
+
---
|
|
254
|
+
layout: false
|
|
255
|
+
---
|
|
256
|
+
<div class="standalone-error-screen">
|
|
257
|
+
<h1>{{ error.status }} - {{ error.message }}</h1>
|
|
258
|
+
</div>
|
|
259
|
+
```
|
|
260
|
+
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# Webspresso Events & Signals Architecture
|
|
2
|
+
|
|
3
|
+
[← Back to AGENTS.md](.agents/AGENTS.md)
|
|
4
|
+
|
|
5
|
+
Webspresso provides an enterprise, zero-dependency **Decoupled Event & Signals System** (`core/events`), combining **AdonisJS Events** (wildcard matching, async parallel/serial/waterfall dispatch, queue bridging, and listener auto-discovery) with **Django Signals** (declarative `Signal` channels, sender filtering, and ORM model signals).
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 1. Overview & Key Capabilities
|
|
10
|
+
|
|
11
|
+
- **Zero External Dependencies**: Built entirely with native Node.js standard modules (`events`, `path`, `fs`).
|
|
12
|
+
- **Synchronous & Asynchronous Emission**:
|
|
13
|
+
- `events.emit(name, data)`: Fast sync/fire-and-forget dispatch.
|
|
14
|
+
- `await events.emitAsync(name, data)`: Parallel concurrent execution (`Promise.allSettled`).
|
|
15
|
+
- `await events.emitSerial(name, data)`: Sequential execution in priority order.
|
|
16
|
+
- `await events.emitWaterfall(name, initialData)`: Sequential pipeline where output of listener $N$ feeds listener $N+1$.
|
|
17
|
+
- **Wildcard & Pattern Matching**:
|
|
18
|
+
- `events.on('order.*', (data, meta) => ...)`
|
|
19
|
+
- `events.on('system.**', (data, meta) => ...)`
|
|
20
|
+
- `events.onAny((eventName, data, meta) => ...)`
|
|
21
|
+
- **Django-Style Signals & Sender Filtering**:
|
|
22
|
+
- `const orderPlaced = defineSignal('order_placed')`
|
|
23
|
+
- `orderPlaced.connect(receiver, { sender: 'Checkout' })`
|
|
24
|
+
- `orderPlaced.send({ orderId: 101 }, { sender: 'Checkout' })`
|
|
25
|
+
- **Background Queue Bridging**:
|
|
26
|
+
- `events.listenAndQueue('user.created', 'send-welcome-email', { delay: 1000 })`
|
|
27
|
+
- Automatically bridges emitted events directly to Webspresso's job queue (`core/queue`).
|
|
28
|
+
- **ORM Model Signals Auto-Bridge**:
|
|
29
|
+
- Automatically translates `ModelEvents` (`User.afterCreate`, `*.afterDelete`) to application events (`model:created`, `user:created`, `model:deleted`) with `sender: ctx.model`.
|
|
30
|
+
- **Automatic Request & Context Decoration**:
|
|
31
|
+
- Injected onto `req.events` on every Express request.
|
|
32
|
+
- Available inside `req.context.events` for services: `ctx.events.emit('order.created', order)`.
|
|
33
|
+
- Accessible on `app.events` and plugin contexts.
|
|
34
|
+
- **Graceful Shutdown Integration**:
|
|
35
|
+
- Cleaned up automatically on graceful shutdown via `ShutdownManager.onShutdown()`.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## 2. Using Events in API Routes & Services
|
|
40
|
+
|
|
41
|
+
### 2.1 Emitting Events in File-Based Routes (`pages/api/...`)
|
|
42
|
+
|
|
43
|
+
```javascript
|
|
44
|
+
// pages/api/orders.post.js
|
|
45
|
+
const { defineApi } = require('webspresso');
|
|
46
|
+
|
|
47
|
+
module.exports = defineApi({
|
|
48
|
+
schema: ({ z }) => ({
|
|
49
|
+
body: z.object({
|
|
50
|
+
total: z.number().positive(),
|
|
51
|
+
items: z.array(z.string()),
|
|
52
|
+
}),
|
|
53
|
+
}),
|
|
54
|
+
async handler(req, res) {
|
|
55
|
+
const order = await req.db.getRepository('Order').create(req.input.body);
|
|
56
|
+
|
|
57
|
+
// Synchronous fire-and-forget or awaited async parallel
|
|
58
|
+
req.events.emit('order.created', order, { sender: 'Order' });
|
|
59
|
+
// or: await req.events.emitAsync('order.created', order);
|
|
60
|
+
|
|
61
|
+
return res.status(201).json(order);
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### 2.2 Emitting Events inside Services (`services/...`)
|
|
67
|
+
|
|
68
|
+
```javascript
|
|
69
|
+
// services/order/checkout.js
|
|
70
|
+
const { defineService } = require('webspresso');
|
|
71
|
+
|
|
72
|
+
module.exports = defineService({
|
|
73
|
+
transaction: true,
|
|
74
|
+
async handle(input, ctx) {
|
|
75
|
+
const order = await ctx.db.getRepository('Order').create(input);
|
|
76
|
+
|
|
77
|
+
// ctx.events is automatically available
|
|
78
|
+
ctx.events.emit('order.checkout.completed', { orderId: order.id, user: ctx.req?.user });
|
|
79
|
+
|
|
80
|
+
return order;
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## 3. Registering Listeners
|
|
88
|
+
|
|
89
|
+
### 3.1 Programmatic Subscriptions
|
|
90
|
+
|
|
91
|
+
```javascript
|
|
92
|
+
const { events } = require('webspresso');
|
|
93
|
+
|
|
94
|
+
// 1. Basic Listener
|
|
95
|
+
const unsub = events.on('order.created', (order, meta) => {
|
|
96
|
+
console.log(`Order ${order.id} received at ${meta.timestamp}`);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// 2. Wildcard Listener
|
|
100
|
+
events.on('order.*', (data, meta) => {
|
|
101
|
+
console.log(`Order event ${meta.event} occurred`);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// 3. One-time Listener
|
|
105
|
+
events.once('app.ready', () => {
|
|
106
|
+
console.log('Application initialized');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// 4. Priority Ordering (higher runs first)
|
|
110
|
+
events.on('user.login', auditLogin, { priority: 100 });
|
|
111
|
+
events.on('user.login', notifyFriends, { priority: 0 });
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### 3.2 File-Based Discovery (`listeners/` directory)
|
|
115
|
+
|
|
116
|
+
Webspresso auto-discovers listeners in the `listeners/` root directory:
|
|
117
|
+
|
|
118
|
+
```javascript
|
|
119
|
+
// listeners/send-welcome-email.js
|
|
120
|
+
const { defineListener } = require('webspresso');
|
|
121
|
+
|
|
122
|
+
module.exports = defineListener('user.created', async (user, meta) => {
|
|
123
|
+
console.log(`Sending welcome email to ${user.email}`);
|
|
124
|
+
});
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Or multiple listeners in a single file:
|
|
128
|
+
|
|
129
|
+
```javascript
|
|
130
|
+
// listeners/audit-events.js
|
|
131
|
+
module.exports = [
|
|
132
|
+
{
|
|
133
|
+
event: 'order.*',
|
|
134
|
+
priority: 50,
|
|
135
|
+
handle: async (data, meta) => {
|
|
136
|
+
console.log(`Audit: ${meta.event}`, data);
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
];
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## 4. Django-Style Signals
|
|
145
|
+
|
|
146
|
+
For decoupled domain events with strict sender boundaries:
|
|
147
|
+
|
|
148
|
+
```javascript
|
|
149
|
+
const { defineSignal } = require('webspresso');
|
|
150
|
+
|
|
151
|
+
// Define signal
|
|
152
|
+
const invoicePaid = defineSignal('invoice_paid');
|
|
153
|
+
|
|
154
|
+
// Connect receiver with sender filter
|
|
155
|
+
invoicePaid.connect(
|
|
156
|
+
(invoice) => {
|
|
157
|
+
console.log(`Invoice ${invoice.id} processed for subscription`);
|
|
158
|
+
},
|
|
159
|
+
{ sender: 'Subscription' }
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
// Trigger signal
|
|
163
|
+
invoicePaid.send({ id: 'INV-100' }, { sender: 'Subscription' });
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## 5. Background Queue Bridging (`listenAndQueue`)
|
|
169
|
+
|
|
170
|
+
Connect application events directly to background jobs without manual boilerplate:
|
|
171
|
+
|
|
172
|
+
```javascript
|
|
173
|
+
// In plugin or server setup:
|
|
174
|
+
app.events.listenAndQueue('order.created', 'send-order-confirmation', {
|
|
175
|
+
delay: 2000,
|
|
176
|
+
attempts: 3,
|
|
177
|
+
});
|
|
178
|
+
```
|
|
179
|
+
When `order.created` is emitted, Webspresso automatically calls `req.queue.dispatch('send-order-confirmation', order, options)`.
|
package/.agents/ORM.md
CHANGED
|
@@ -311,4 +311,153 @@ const mixed = await articleRepo.query()
|
|
|
311
311
|
| **PostgreSQL** | `'auto'` / `'fts'` | `to_tsvector @@ plainto_tsquery` / `websearch_to_tsquery` | Native Postgres tsvector search. Pass `{ rank: true }` to order by `ts_rank` relevance. |
|
|
312
312
|
| **MySQL** | `'fts'` | `MATCH(...) AGAINST(? IN BOOLEAN MODE)` | Requires FULLTEXT index on table. |
|
|
313
313
|
|
|
314
|
+
---
|
|
315
|
+
|
|
316
|
+
## 9. Relational Cascading Soft-Delete & Selective Restore (`cascade: true`)
|
|
317
|
+
|
|
318
|
+
Webspresso ORM supports automated relational cascade operations for `hasMany` and `hasOne` relationships. When a parent record is soft-deleted or restored, child records are synchronized automatically.
|
|
319
|
+
|
|
320
|
+
### 9.1 Relation Configuration
|
|
321
|
+
|
|
322
|
+
Configure `cascade` directly inside your model relations:
|
|
323
|
+
|
|
324
|
+
```javascript
|
|
325
|
+
defineModel({
|
|
326
|
+
name: 'Author',
|
|
327
|
+
table: 'authors',
|
|
328
|
+
schema: zdb.schema({
|
|
329
|
+
id: zdb.id(),
|
|
330
|
+
name: zdb.string(),
|
|
331
|
+
deleted_at: zdb.timestamp({ nullable: true }),
|
|
332
|
+
}),
|
|
333
|
+
relations: {
|
|
334
|
+
// 1. Full cascade (soft-delete, hard-delete, and restore)
|
|
335
|
+
posts: {
|
|
336
|
+
type: 'hasMany',
|
|
337
|
+
model: () => 'Post', // or () => Post
|
|
338
|
+
foreignKey: 'author_id',
|
|
339
|
+
cascade: true,
|
|
340
|
+
},
|
|
341
|
+
// 2. hasOne cascade
|
|
342
|
+
profile: {
|
|
343
|
+
type: 'hasOne',
|
|
344
|
+
model: () => 'Profile',
|
|
345
|
+
foreignKey: 'author_id',
|
|
346
|
+
cascade: true,
|
|
347
|
+
},
|
|
348
|
+
// 3. Delete-only cascade (child is deleted with parent, but NOT restored)
|
|
349
|
+
tokens: {
|
|
350
|
+
type: 'hasMany',
|
|
351
|
+
model: () => 'ApiToken',
|
|
352
|
+
foreignKey: 'author_id',
|
|
353
|
+
cascade: 'delete', // or { delete: true, restore: false }
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
scopes: {
|
|
357
|
+
softDelete: true,
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
### 9.2 Cascade Deletion Behavior
|
|
363
|
+
|
|
364
|
+
- **Soft Delete (`repo.delete(id)`)**:
|
|
365
|
+
When an Author is soft-deleted, all active child records in cascading relations (`hasMany`, `hasOne`) are soft-deleted with the **exact same `deleted_at` timestamp**.
|
|
366
|
+
Cascade is recursive: If `Post` also defines `comments` with `cascade: true`, all comments under those posts are cascaded automatically.
|
|
367
|
+
- **Hard Delete (`repo.forceDelete(id)`)**:
|
|
368
|
+
When `forceDelete` is called (or when parent is a non-softDelete model), cascading child records are deleted **before** the parent to avoid foreign-key constraint violations (`FOREIGN KEY constraint failed`).
|
|
369
|
+
- **Cache Invalidation**:
|
|
370
|
+
The ORM query cache for all affected related models is automatically invalidated.
|
|
371
|
+
|
|
372
|
+
### 9.3 Selective Restore (Smart Timestamp Matching)
|
|
373
|
+
|
|
374
|
+
A classic pitfall in soft-delete architectures is resurrecting child records that the user deliberately deleted prior to deleting the parent account:
|
|
375
|
+
|
|
376
|
+
```
|
|
377
|
+
T1 (10:00): User manually deletes Post #1 (deleted_at = 10:00).
|
|
378
|
+
T2 (12:00): User account is soft-deleted (deleted_at = 12:00).
|
|
379
|
+
Post #2 and Post #3 receive deleted_at = 12:00.
|
|
380
|
+
T3 (15:00): User account is restored.
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
By default, Webspresso ORM executes **selective restore**:
|
|
384
|
+
- It restores only child records where `child.deleted_at === parent.deleted_at` (Post #2 and Post #3).
|
|
385
|
+
- Post #1 remains soft-deleted because its `deleted_at` was `10:00`!
|
|
386
|
+
|
|
387
|
+
```javascript
|
|
388
|
+
// Default: Safe selective restore (protects previously deleted children)
|
|
389
|
+
await authorRepo.restore(authorId);
|
|
390
|
+
|
|
391
|
+
// Optional: Force restore ALL children regardless of when they were deleted
|
|
392
|
+
await authorRepo.restore(authorId, { allChildren: true });
|
|
393
|
+
|
|
394
|
+
// Optional: Bypass cascading for a specific operation
|
|
395
|
+
await authorRepo.delete(authorId, { cascade: false });
|
|
396
|
+
await authorRepo.restore(authorId, { cascade: false });
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
### 9.4 Cycle & Recursion Safety
|
|
400
|
+
|
|
401
|
+
If your data model contains circular relations (e.g. `User.partner` <-> `Partner.user`), Webspresso ORM uses an internal visited set (`model:id`) to terminate recursion cleanly without stack overflows.
|
|
402
|
+
|
|
403
|
+
---
|
|
404
|
+
|
|
405
|
+
## 10. Django-inspired Auto-Migrations (`db:makemigrations`)
|
|
406
|
+
|
|
407
|
+
Webspresso supports automatic schema diffing against your live database, similar to Django's `makemigrations`. It inspects your `models/*.js` definitions, checks the current database schema, and automatically generates required Knex migration files (`createTable` or `alterTable` with `dropColumn` rollbacks):
|
|
408
|
+
|
|
409
|
+
```bash
|
|
410
|
+
# Preview changes without writing files
|
|
411
|
+
npx webspresso db:makemigrations --dry-run
|
|
412
|
+
|
|
413
|
+
# Detect model changes and generate migration file
|
|
414
|
+
npx webspresso db:makemigrations
|
|
415
|
+
|
|
416
|
+
# Custom migration name
|
|
417
|
+
npx webspresso db:makemigrations --name add_profile_fields
|
|
418
|
+
|
|
419
|
+
# Apply migrations
|
|
420
|
+
npx webspresso db:migrate
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
Programmatic usage via `db.migrate`:
|
|
424
|
+
```javascript
|
|
425
|
+
// Check diff
|
|
426
|
+
const diff = await db.migrate.diff();
|
|
427
|
+
if (diff.hasChanges) {
|
|
428
|
+
console.log(diff.changes);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Generate files
|
|
432
|
+
const result = await db.migrate.makeMigrations();
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
---
|
|
436
|
+
|
|
437
|
+
## 11. AdonisJS-inspired Model Factories (`defineFactory`)
|
|
438
|
+
|
|
439
|
+
For unit testing, integration tests, and dev database seeding, Webspresso provides declarative Model Factories inspired by AdonisJS Lucid:
|
|
440
|
+
|
|
441
|
+
```javascript
|
|
442
|
+
import { defineFactory } from 'webspresso';
|
|
443
|
+
|
|
444
|
+
export const UserFactory = defineFactory('User', ({ faker, index }) => ({
|
|
445
|
+
name: faker.person.fullName(),
|
|
446
|
+
email: faker.internet.email(),
|
|
447
|
+
role: 'user',
|
|
448
|
+
isActive: true,
|
|
449
|
+
}))
|
|
450
|
+
.state('admin', () => ({ role: 'admin' }))
|
|
451
|
+
.state('suspended', () => ({ isActive: false }));
|
|
452
|
+
|
|
453
|
+
// In-memory object generation (No DB hit)
|
|
454
|
+
const fakeUser = UserFactory.make({ name: 'Custom Name' });
|
|
455
|
+
const fakeUsers = UserFactory.count(5).make();
|
|
456
|
+
|
|
457
|
+
// Database persistence via Repository
|
|
458
|
+
const user = await UserFactory.create({ db });
|
|
459
|
+
const admins = await UserFactory.count(3).apply('admin').create({ db });
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
|
|
314
463
|
|
package/.agents/PLUGINS.md
CHANGED
|
@@ -112,7 +112,44 @@ Zero-dependency B2B & Developer API Key Management Plugin with standard prefix f
|
|
|
112
112
|
- `getMigrationTemplate(tableName)`: Knex migration script creating `api_keys` table.
|
|
113
113
|
- `createApiKeyModel(tableName)`: Webspresso ORM model definition with hidden `key_hash`.
|
|
114
114
|
|
|
115
|
-
### 2.
|
|
115
|
+
### 2.3 `webhooksPlugin` (`plugins/webhooks`)
|
|
116
|
+
Outgoing Webhook Dispatcher and Management Plugin with zero external dependencies. Dispatches events to subscribed external HTTP endpoints, signs payloads with HMAC-SHA256 (`X-Webspresso-Signature` and standard timestamped `webhook-signature`), filters events with wildcards (`*`, `order.*`), automatically retries failed deliveries using exponential backoff, hooks directly into Webspresso Queue (`core/queue`), and provides an interactive Mithril.js Admin Panel management UI.
|
|
117
|
+
- **Options**:
|
|
118
|
+
- `db` / `knex`: Webspresso Database or Knex instance.
|
|
119
|
+
- `endpointsTable`: Database table for endpoints (default `'webhook_endpoints'`).
|
|
120
|
+
- `deliveriesTable`: Database table for delivery logs (default `'webhook_deliveries'`).
|
|
121
|
+
- `maxAttempts`: Maximum retry attempts on delivery failure (default `3`).
|
|
122
|
+
- `backoffBaseMs`: Base delay for exponential backoff in milliseconds (default `1000`).
|
|
123
|
+
- `timeoutMs`: HTTP request timeout in milliseconds (default `10000`).
|
|
124
|
+
- `wrapPayload`: Boolean (default `true`), wrap data in standard `{ id, event, timestamp, data }` event envelope.
|
|
125
|
+
- `queue`: Custom `QueueManager` instance (auto-detected from `ctx.app.queue` if present).
|
|
126
|
+
- `endpoints`: Static in-memory endpoints array (useful for testing or serverless setups).
|
|
127
|
+
- `apiPrefix`: Admin REST API prefix (default `'/webhooks'`).
|
|
128
|
+
- `includeDefaultPage`: Register Admin UI page and sidebar menu item (default `true`).
|
|
129
|
+
- `pageTitle`: Admin page title (default `'Webhooks'`).
|
|
130
|
+
- `menuOrder`: Admin sidebar order (default `92`).
|
|
131
|
+
- **Request & Application Context Injected**:
|
|
132
|
+
- `req.webhooks`: Direct reference to the `WebhookDispatcher` instance.
|
|
133
|
+
- `app.webhooks`: Direct reference on the Webspresso Application instance.
|
|
134
|
+
- **Built-in Services Registered**:
|
|
135
|
+
- `'webhook.dispatch'`: Dispatch events via `ctx.service('webhook.dispatch', { event, payload })`.
|
|
136
|
+
- `'webhook.ping'`: Test endpoint connectivity via `ctx.service('webhook.ping', { endpointId })`.
|
|
137
|
+
- `'webhook.retry'`: Manually retry a delivery via `ctx.service('webhook.retry', { deliveryId })`.
|
|
138
|
+
- **Programmatic API (`webhooksPlugin.api` / `dispatcher`)**:
|
|
139
|
+
- `dispatch(event, payload, options)`: Match endpoints and deliver payload asynchronously (or await with `wait: true`).
|
|
140
|
+
- `pingEndpoint(endpointId)`: Send a test `ping` event to verify destination connectivity.
|
|
141
|
+
- `retryDelivery(deliveryId)`: Re-send a failed delivery with incremented attempt count.
|
|
142
|
+
- `createEndpoint(data)`: Register a new webhook endpoint with custom or auto-generated `whsec_` secret.
|
|
143
|
+
- `updateEndpoint(id, data)` / `deleteEndpoint(id)`: Update or delete endpoint records.
|
|
144
|
+
- `listEndpoints(filters)`: List endpoints with delivery statistics.
|
|
145
|
+
- `listDeliveries(filters)`: Query delivery logs with filtering by endpoint, event, and success status.
|
|
146
|
+
- `purgeDeliveries(options)`: Purge logs older than specified retention days.
|
|
147
|
+
- `signPayload(payload, secret, options)`: Compute HMAC-SHA256 signature (`sha256=...` and `t=...,v1=...`).
|
|
148
|
+
- `verifySignature(payload, header, secret, options)`: Constant-time signature verification with drift tolerance.
|
|
149
|
+
- `getMigrationTemplate()`: Knex migration script for `webhook_endpoints` and `webhook_deliveries`.
|
|
150
|
+
- `getModels()`: Webspresso ORM model definitions for `WebhookEndpoint` and `WebhookDelivery`.
|
|
151
|
+
|
|
152
|
+
### 2.4 `adminPanelPlugin` (`plugins/admin-panel`)
|
|
116
153
|
Mithril.js SPA Admin Panel mounted at `/_admin`.
|
|
117
154
|
- **Options**: `{ path: '/_admin', db, title: 'Webspresso Admin' }`
|
|
118
155
|
- **Features**: Auto CRUD screens for ORM models, user staff auth, custom page registration, widgets, field renderers.
|
|
@@ -131,14 +168,14 @@ Multipart file upload manager with local / cloud storage providers.
|
|
|
131
168
|
|
|
132
169
|
### 2.5 `fileManagerPlugin` (`plugins/file-manager`)
|
|
133
170
|
Filesystem-first media library, directory explorer, and modal asset picker for Admin Panel.
|
|
134
|
-
- **Options**: `{ baseDir: './public/uploads', publicBasePath: '/uploads', apiPrefix: '/files', pageTitle: '
|
|
171
|
+
- **Options**: `{ baseDir: './public/uploads', publicBasePath: '/uploads', apiPrefix: '/files', pageTitle: 'File Manager', maxUploadBytes: 10485760, allowedExtensions, allowedMimeTypes, includePage: true }`
|
|
135
172
|
- **Features**:
|
|
136
173
|
- Directory tree navigation (breadcrumbs), safe subfolder creation (`mkdir`), item rename, delete, and move.
|
|
137
174
|
- Path traversal attack protection (`resolveSafePath`).
|
|
138
175
|
- Grid (thumbnail gallery) & List (table) view modes.
|
|
139
176
|
- Search by filename and type filter (Images, Documents, Videos, Audio, Archives, Code).
|
|
140
177
|
- Media preview modal with copy URL and direct playback/preview.
|
|
141
|
-
- "
|
|
178
|
+
- "Choose from Media Library" modal picker integrated with `FileUploadField` and `zdb.file()` form fields.
|
|
142
179
|
- Built-in services: `fileManager.list`, `fileManager.mkdir`, `fileManager.rename`, `fileManager.delete`, `fileManager.move`, `fileManager.upload`.
|
|
143
180
|
|
|
144
181
|
### 2.6 `corsPlugin` (`plugins/cors`)
|
|
@@ -256,19 +293,19 @@ Admin mutation tracking, record change timeline, and audit history with schedule
|
|
|
256
293
|
- **Options**:
|
|
257
294
|
- `db`: Database instance (must expose `.knex`).
|
|
258
295
|
- `tableName`: Database table name (default `'audit_logs'`).
|
|
259
|
-
- `registerRecordAction`: Register built-in "
|
|
296
|
+
- `registerRecordAction`: Register built-in "Timeline" action on all admin models (default `true`).
|
|
260
297
|
- `retentionDays`: Days to retain audit trail entries (default `90`).
|
|
261
298
|
- `cleanupIntervalMs`: Background cleanup timer frequency (default 24h).
|
|
262
299
|
- `autoCleanup`: Enable scheduled background purge (default `true`).
|
|
263
300
|
- `cleanupOnStart`: Run initial purge shortly after boot (default `true`).
|
|
264
301
|
- `retention`: `{ enabled, days: 90, intervalMs: 86400000, runOnStart: true }` (or `false` to disable).
|
|
265
|
-
- **
|
|
266
|
-
- **Dual Mode**: Switch seamlessly between **
|
|
302
|
+
- **Timeline UI Features**:
|
|
303
|
+
- **Dual Mode**: Switch seamlessly between **Timeline View** and **Table View**.
|
|
267
304
|
- **Record Focus Mode**: Clicking any `Model #ID` tag instantly filters and displays that specific record's revision timeline.
|
|
268
305
|
- **Visual Spine & Status Icons**: Color-coded action nodes for `create` (emerald +), `update` (blue ✏️), `delete` (rose 🗑️), and `restore` (amber ↺).
|
|
269
306
|
- **Changed Fields Diff**: Clearly lists modified fields (e.g. `title`, `price`) on every update event.
|
|
270
307
|
- **Expandable Details**: Inspect full HTTP request path, actor IP, user agent, and pretty-printed JSON metadata.
|
|
271
|
-
- **Built-in Record Action**: One-click "
|
|
308
|
+
- **Built-in Record Action**: One-click "Timeline" button on record lists and edit forms jumps straight to that record's audit history.
|
|
272
309
|
- **API & Admin Panel Endpoints**:
|
|
273
310
|
- `GET /_admin/api/audit-logs`: List paginated logs with filters (`model`, `recordId`, `action`, `search`, `from`, `to`).
|
|
274
311
|
- `GET /_admin/api/audit-logs/timeline/:model/:id`: Dedicated chronological record change timeline with summary stats.
|
package/.agents/QUEUE.md
CHANGED
|
@@ -162,27 +162,30 @@ Webspresso includes a native, zero-dependency task scheduler with a POSIX-compli
|
|
|
162
162
|
|
|
163
163
|
### 1. Cron Expression Syntax
|
|
164
164
|
- Standard 5 fields: `minute hour day-of-month month day-of-week`
|
|
165
|
-
- Wildcards:
|
|
166
|
-
- Steps: `*/5`, `10-30/5`
|
|
167
|
-
- Ranges: `1-5`, `MON-FRI`, `JAN-DEC`
|
|
165
|
+
- Wildcards: `*`, `?` (supported in day-of-month and day-of-week)
|
|
166
|
+
- Steps & Offsets: `*/5`, `10/15` (starting at minute 10, every 15 min), `10-30/5`
|
|
167
|
+
- Ranges: `1-5`, `5-7` (Fri-Sun), `MON-FRI`, `MON-SUN`, `JAN-DEC`
|
|
168
168
|
- Lists: `1,15,30`, `MON,WED,FRI`
|
|
169
169
|
- Descriptive aliases:
|
|
170
170
|
- `@yearly`, `@annually` (`0 0 1 1 *`)
|
|
171
171
|
- `@monthly` (`0 0 1 * *`)
|
|
172
172
|
- `@weekly` (`0 0 * * 0`)
|
|
173
|
+
- `@weekdays`, `@workdays` (`0 0 * * 1-5`)
|
|
174
|
+
- `@weekends` (`0 0 * * 6,0`)
|
|
173
175
|
- `@daily`, `@midnight` (`0 0 * * *`)
|
|
174
|
-
- `@hourly` (`0 * * * *`)
|
|
176
|
+
- `@hourly`, `@every_hour` (`0 * * * *`)
|
|
175
177
|
- `@every_minute`, `@minutely` (`* * * * *`)
|
|
176
178
|
- `@every <N>m` (e.g. `@every 5m` → `*/5 * * * *`)
|
|
177
179
|
- `@every <N>h` (e.g. `@every 2h` → `0 */2 * * *`)
|
|
180
|
+
- `@every <N>d` (e.g. `@every 2d` → `0 0 */2 * *`)
|
|
178
181
|
|
|
179
182
|
### 2. Programmatic Scheduling
|
|
180
183
|
```js
|
|
181
|
-
// Schedule daily digest at 09:00 AM
|
|
184
|
+
// Schedule daily digest at 09:00 AM with Timezone support
|
|
182
185
|
queue.schedule('0 9 * * *', 'reports.daily-digest', { channel: 'slack' }, {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
+
timezone: 'Europe/Istanbul', // IANA timezone support ('Europe/Istanbul', 'America/New_York')
|
|
187
|
+
overlap: false, // Prevent concurrent execution if previous run is still active (default: false)
|
|
188
|
+
maxRuns: 10, // Optional maximum executions before auto-unscheduling
|
|
186
189
|
});
|
|
187
190
|
|
|
188
191
|
// Or use the .cron() alias
|
|
@@ -190,9 +193,12 @@ queue.cron('@every 15m', 'metrics.collect');
|
|
|
190
193
|
|
|
191
194
|
// Query schedules
|
|
192
195
|
const schedules = queue.getSchedules();
|
|
193
|
-
// [{ id, name, cronExpression, nextRunAt, lastRunAt, isRunning, runsCount, ... }]
|
|
196
|
+
// [{ id, name, cronExpression, nextRunAt, lastRunAt, isRunning, activeJobId, activeJobIds, runsCount, ... }]
|
|
194
197
|
|
|
195
|
-
//
|
|
198
|
+
// Manually trigger a schedule immediately
|
|
199
|
+
await queue.triggerSchedule('reports.daily-digest');
|
|
200
|
+
|
|
201
|
+
// Unschedule by schedule ID or job name (removes all schedules matching the job name)
|
|
196
202
|
queue.unschedule('metrics.collect');
|
|
197
203
|
```
|
|
198
204
|
|
|
@@ -201,8 +207,10 @@ queue.unschedule('metrics.collect');
|
|
|
201
207
|
| :--- | :--- | :--- | :--- |
|
|
202
208
|
| `id` | `string` | `schedule:${job}:${cron}` | Custom unique identifier |
|
|
203
209
|
| `overlap` | `boolean` | `false` | When `false`, skips tick if a previous job run is still processing |
|
|
210
|
+
| `preventOverlap` | `boolean` | `true` | Alias for `overlap: false` |
|
|
204
211
|
| `maxRuns` | `number` | `null` | Automatically unschedules task after N runs |
|
|
205
212
|
| `utc` | `boolean` | `false` | Evaluate cron expressions in UTC instead of local timezone |
|
|
213
|
+
| `timezone` (`tz`) | `string` | `null` | IANA timezone name (e.g. `'Europe/Istanbul'`, `'America/New_York'`) |
|
|
206
214
|
| `runImmediately` | `boolean` | `false` | Triggers a run right after scheduling |
|
|
207
215
|
|
|
208
216
|
### 4. File-Based Cron Discovery (`jobs/`)
|
package/.agents/ROUTING.md
CHANGED
|
@@ -400,9 +400,105 @@ Pages under `pages/` automatically inherit ancestor layouts:
|
|
|
400
400
|
</div>
|
|
401
401
|
```
|
|
402
402
|
|
|
403
|
+
#### 3. Folder-Level Error Boundaries (`_error.njk` & `_error.js`)
|
|
404
|
+
When an exception occurs during SSR lifecycle execution (in `load()`, template rendering, hooks, or nested layout loaders):
|
|
405
|
+
- **Ancestral Discovery**: Webspresso searches up the directory hierarchy from the route's directory up to `pages/` for the nearest `_error.njk`.
|
|
406
|
+
- **Isolated Layout Preservation**: `_error.njk` is automatically wrapped inside the surrounding ancestor `_layout.njk` chain, keeping the UI intact (e.g. an error in `/dashboard/billing/invoices` renders the error card inside the dashboard sidebar and header).
|
|
407
|
+
- **Companion Loader (`_error.js`)**: Can export `load({ error, req, res, ctx, db })` to attach custom error data or report exceptions.
|
|
408
|
+
- **Status Code Propagation**: HTTP status is set to `error.status || error.statusCode || 500` (e.g., throwing `NotFoundError` returns HTTP 404).
|
|
409
|
+
- **Opt-Out**: Disable layout wrapping with `layout: false` in `_error.js` or frontmatter (`--- layout: false ---`).
|
|
410
|
+
- **Fallback**: If no `_error.njk` exists in any ancestor directory, execution falls through to the global Express error boundary (`app.setErrorHandler` / 500 page).
|
|
411
|
+
|
|
412
|
+
```html
|
|
413
|
+
{# pages/dashboard/_error.njk #}
|
|
414
|
+
<div class="p-6 bg-red-50 dark:bg-red-950/30 rounded-xl border border-red-200">
|
|
415
|
+
<h2 class="text-xl font-bold text-red-700">Error {{ error.status }}</h2>
|
|
416
|
+
<p class="text-red-600 mt-2">{{ error.message }}</p>
|
|
417
|
+
{% if supportEmail %}<p class="mt-4 text-sm">Need help? Email {{ supportEmail }}</p>{% endif %}
|
|
418
|
+
</div>
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
#### 4. Folder-Level Loading Skeletons (`_loading.njk`)
|
|
422
|
+
In SSR streaming mode (`stream: true` or `renderStream`):
|
|
423
|
+
- **Automatic Discovery**: Discovers the nearest `_loading.njk` up the directory chain.
|
|
424
|
+
- **Auto-Populate Slots**: Any empty `<div data-stream-target="slotName"></div>` in the page template is automatically populated with the `_loading.njk` skeleton HTML in the initial flushed shell chunk!
|
|
425
|
+
- **Context Availability**: Exposed as `{{ loadingSkeleton | safe }}` and `{{ loadingHtml | safe }}` in the template context.
|
|
426
|
+
- **Layout Integration**: Cascading `_layout.njk` templates automatically wrap the streaming shell.
|
|
427
|
+
|
|
428
|
+
```html
|
|
429
|
+
{# pages/dashboard/_loading.njk #}
|
|
430
|
+
<div class="animate-pulse space-y-4">
|
|
431
|
+
<div class="h-8 bg-slate-200 dark:bg-slate-800 rounded w-1/4"></div>
|
|
432
|
+
<div class="h-32 bg-slate-200 dark:bg-slate-800 rounded"></div>
|
|
433
|
+
</div>
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
---
|
|
437
|
+
|
|
438
|
+
## 7. Form Handling, Flash Messages & Old Input (AdonisJS & Django-inspired)
|
|
439
|
+
|
|
440
|
+
For classic SSR forms (POST-Redirect-GET), Webspresso provides built-in flash data propagation and Django-style ModelForm rendering helpers.
|
|
441
|
+
|
|
442
|
+
### 7.1 Redirect with Old Input & Validation Errors
|
|
443
|
+
In your form submission handler (API or custom POST action):
|
|
444
|
+
```javascript
|
|
445
|
+
// pages/api/auth/register.post.js
|
|
446
|
+
module.exports = {
|
|
447
|
+
handler: async (req, res) => {
|
|
448
|
+
const { email, password } = req.body;
|
|
449
|
+
|
|
450
|
+
if (!email.includes('@')) {
|
|
451
|
+
return res
|
|
452
|
+
.withInput() // Preserves req.body (sanitizes sensitive fields like password)
|
|
453
|
+
.withErrors({ email: 'Please enter a valid email address' })
|
|
454
|
+
.withFlash('warning', 'Please review the highlighted errors')
|
|
455
|
+
.redirect('/register');
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
await req.service('user.create', { email, password });
|
|
459
|
+
return res.withFlash('success', 'Account created! Please log in.').redirect('/login');
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
### 7.2 Nunjucks Template Helpers (`fsy`)
|
|
465
|
+
In your Nunjucks templates (`pages/*.njk`):
|
|
466
|
+
```html
|
|
467
|
+
{% if fsy.hasFlash('success') %}
|
|
468
|
+
<div class="alert alert-success">{{ fsy.flash('success') }}</div>
|
|
469
|
+
{% endif %}
|
|
470
|
+
|
|
471
|
+
<form method="POST" action="/api/auth/register">
|
|
472
|
+
{{ fsy.csrf() | safe }}
|
|
473
|
+
|
|
474
|
+
<!-- Manual field with old value and inline error -->
|
|
475
|
+
<div class="mb-3">
|
|
476
|
+
<label class="form-label">Email</label>
|
|
477
|
+
<input type="email" name="email"
|
|
478
|
+
value="{{ fsy.old('email') }}"
|
|
479
|
+
class="form-control {% if fsy.hasError('email') %}is-invalid{% endif %}">
|
|
480
|
+
{% if fsy.hasError('email') %}
|
|
481
|
+
<div class="invalid-feedback">{{ fsy.error('email') }}</div>
|
|
482
|
+
{% endif %}
|
|
483
|
+
</div>
|
|
484
|
+
|
|
485
|
+
<!-- Or use the Django-inspired field helper (one-liner) -->
|
|
486
|
+
{{ fsy.field('email', { type: 'email', placeholder: 'you@example.com' }) | safe }}
|
|
487
|
+
|
|
488
|
+
<button type="submit" class="btn btn-primary">Register</button>
|
|
489
|
+
</form>
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
### 7.3 Django ModelForm Helper (`fsy.formFor`)
|
|
493
|
+
Generate an entire HTML form from an ORM model definition with automatic widgets and validation error mapping:
|
|
494
|
+
```html
|
|
495
|
+
{# Automatically excludes id, created_at, updated_at and generates appropriate widgets #}
|
|
496
|
+
{{ fsy.formFor(req.db.getRepository('Article'), { action: '/articles', submitLabel: 'Publish' }) | safe }}
|
|
497
|
+
```
|
|
498
|
+
|
|
403
499
|
---
|
|
404
500
|
|
|
405
|
-
##
|
|
501
|
+
## 8. Summary Checklist for Agents
|
|
406
502
|
|
|
407
503
|
When a user asks to add or modify endpoints:
|
|
408
504
|
1. Identify the URL and method (e.g. `POST /api/auth/register` or `POST /auth/login`).
|