webspresso 0.0.96 → 0.0.97
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/skills/webspresso-admin-and-plugins/SKILL.md +122 -0
- package/.agents/skills/webspresso-framework/SKILL.md +56 -0
- package/.agents/skills/webspresso-orm-and-database/SKILL.md +119 -0
- package/.agents/skills/webspresso-routing-and-api/SKILL.md +114 -0
- package/.agents/skills/webspresso-services/SKILL.md +101 -0
- package/bin/commands/admin-password.js +2 -1
- package/bin/commands/admin-setup.js +1 -1
- package/bin/commands/agents-init.js +51 -11
- package/bin/commands/audit-prune.js +1 -1
- package/bin/commands/favicon-generate.js +1 -1
- package/bin/commands/orm-map.js +5 -4
- package/bin/commands/polar-migrate.js +1 -1
- package/bin/commands/seed.js +1 -1
- package/core/auth/hash.js +10 -5
- package/package.json +32 -12
- package/plugins/admin-panel/app.js +10 -9
- package/plugins/admin-panel/core/api-extensions.js +4 -0
- package/plugins/basic-auth/index.js +3 -1
- package/plugins/content/client/inline-edit.js +12 -12
- package/plugins/cors.js +6 -7
- package/plugins/file-manager/core.js +1 -1
- package/plugins/file-manager/security.js +33 -23
- package/plugins/index.js +45 -87
- package/plugins/mcp/transports/sse.js +4 -2
- package/plugins/site-analytics/admin-component.js +7 -8
- package/src/file-router.js +3 -1
- package/src/services/discovery.js +6 -1
- package/src/services/index.js +6 -13
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: webspresso-admin-and-plugins
|
|
3
|
+
description: >-
|
|
4
|
+
Extend the Mithril.js Admin Panel SPA and configure official plugins in Webspresso.
|
|
5
|
+
Use when creating custom admin pages (component.js, page.json), custom field renderers,
|
|
6
|
+
dashboard widgets, actions, or configuring plugins like fileManager, xlsx, csv, polar, realtime, csrf, cors, and basicAuth.
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Webspresso Admin Panel & Plugins Skill
|
|
10
|
+
|
|
11
|
+
The Admin Panel is a modular Mithril.js SPA mounted at `/_admin` powered by `adminPanelPlugin({ db })`.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 1. Custom Admin Pages (`pagesDir` or `registerPageDir`)
|
|
16
|
+
|
|
17
|
+
Folder structure for auto-discovered admin pages:
|
|
18
|
+
```text
|
|
19
|
+
admin/pages/
|
|
20
|
+
└── system-stats/
|
|
21
|
+
├── page.json
|
|
22
|
+
└── component.js
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### `page.json`
|
|
26
|
+
```json
|
|
27
|
+
{
|
|
28
|
+
"id": "system-stats",
|
|
29
|
+
"label": "System Statistics",
|
|
30
|
+
"icon": "activity",
|
|
31
|
+
"order": 10,
|
|
32
|
+
"layout": true,
|
|
33
|
+
"permission": "admin"
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### `component.js` (Clean Mithril Component)
|
|
38
|
+
```javascript
|
|
39
|
+
module.exports = {
|
|
40
|
+
oninit(vnode) {
|
|
41
|
+
vnode.state.stats = null;
|
|
42
|
+
vnode.state.loading = true;
|
|
43
|
+
m.request({ method: 'GET', url: '/_admin/api/custom/stats' }).then((res) => {
|
|
44
|
+
vnode.state.stats = res;
|
|
45
|
+
vnode.state.loading = false;
|
|
46
|
+
});
|
|
47
|
+
},
|
|
48
|
+
view(vnode) {
|
|
49
|
+
if (vnode.state.loading) return m('.p-6', 'Loading stats...');
|
|
50
|
+
return m('.space-y-4', [
|
|
51
|
+
m('h2.text-xl.font-bold', 'System Overview'),
|
|
52
|
+
m('.grid.grid-cols-3.gap-4', [
|
|
53
|
+
m('.card.p-4.border.rounded', [
|
|
54
|
+
m('.text-sm.text-gray-500', 'Total Users'),
|
|
55
|
+
m('.text-2xl.font-semibold', vnode.state.stats.usersCount),
|
|
56
|
+
]),
|
|
57
|
+
]),
|
|
58
|
+
]);
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## 2. Admin Panel Extension Points
|
|
66
|
+
|
|
67
|
+
```javascript
|
|
68
|
+
// Register custom widget in dashboard
|
|
69
|
+
registry.registerWidget('recent-signups', {
|
|
70
|
+
label: 'Recent Signups',
|
|
71
|
+
component: require('./widgets/recent-signups'),
|
|
72
|
+
width: 'half', // 'full' | 'half' | 'third'
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Register single record action button
|
|
76
|
+
registry.registerAction('User', {
|
|
77
|
+
name: 'impersonate',
|
|
78
|
+
label: 'Impersonate',
|
|
79
|
+
icon: 'user-check',
|
|
80
|
+
handler: async (record) => { ... },
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Register custom field display/edit renderer
|
|
84
|
+
registry.registerFieldRenderer('color-picker', {
|
|
85
|
+
display: (val) => m('.w-6.h-6.rounded', { style: { backgroundColor: val } }),
|
|
86
|
+
edit: (val, onChange) => m('input[type=color]', { value: val, onchange: (e) => onChange(e.target.value) }),
|
|
87
|
+
});
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## 3. Official Plugin Catalog & Usage
|
|
93
|
+
|
|
94
|
+
In `createApp({ plugins: [...] })`:
|
|
95
|
+
|
|
96
|
+
```javascript
|
|
97
|
+
const {
|
|
98
|
+
adminPanelPlugin,
|
|
99
|
+
fileManagerPlugin,
|
|
100
|
+
xlsxPlugin,
|
|
101
|
+
csvPlugin,
|
|
102
|
+
basicAuthPlugin,
|
|
103
|
+
corsPlugin,
|
|
104
|
+
csrfPlugin,
|
|
105
|
+
realtimePlugin,
|
|
106
|
+
polarPlugin,
|
|
107
|
+
} = require('webspresso/plugins');
|
|
108
|
+
|
|
109
|
+
createApp({
|
|
110
|
+
plugins: [
|
|
111
|
+
adminPanelPlugin({ db }),
|
|
112
|
+
fileManagerPlugin({ baseDir: './public/uploads', publicBasePath: '/uploads' }),
|
|
113
|
+
xlsxPlugin(),
|
|
114
|
+
csvPlugin(),
|
|
115
|
+
basicAuthPlugin({ users: { admin: process.env.ADMIN_BASIC_AUTH_PASS }, routes: ['/metrics'] }),
|
|
116
|
+
corsPlugin({ origin: ['https://example.com'], credentials: true }),
|
|
117
|
+
csrfPlugin({ cookie: true }),
|
|
118
|
+
realtimePlugin({ adapter: 'websocket' }),
|
|
119
|
+
polarPlugin({ apiKey: process.env.POLAR_ACCESS_TOKEN }),
|
|
120
|
+
],
|
|
121
|
+
});
|
|
122
|
+
```
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: webspresso-framework
|
|
3
|
+
description: >-
|
|
4
|
+
Core workflow and guide for Webspresso, a lightweight, zero-dependency-sprawl full-stack
|
|
5
|
+
Node.js SSR and API framework. Use when scaffolding, architecting, building, or debugging
|
|
6
|
+
Webspresso applications, configuring plugins, dual auth, response compression, graceful shutdown,
|
|
7
|
+
and error boundaries.
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Webspresso Framework Master Guide
|
|
11
|
+
|
|
12
|
+
Webspresso is a lightweight, zero-dependency-sprawl full-stack Node.js SSR & API framework featuring Next.js/Nuxt-style file-based routing, an intuitive Knex-based ORM, a modular plugin ecosystem, and a customizable Mithril.js SPA Admin Panel.
|
|
13
|
+
|
|
14
|
+
## 🛡️ 4-Step Mandatory Agent Workflow Protocol
|
|
15
|
+
|
|
16
|
+
1. **Discover & Triage**: Identify relevant topic guides (`.agents/`) and inspect existing files in `pages/api/`, `models/`, `services/`.
|
|
17
|
+
2. **Contract Adherence**: Export `{ schema, middleware, handler }` for APIs, `defineModel` for ORM, `defineService` for services.
|
|
18
|
+
3. **Zero External Dependencies**: Core functionality relies strictly on native Node.js modules (`crypto`, `path`, `fs`, `zlib`, `events`). NEVER install ad-hoc packages for JWT, passport, express-session, or custom routers.
|
|
19
|
+
4. **Targeted Verification**: Run specific Vitest unit tests (`npx vitest run tests/...`) to ensure zero regressions.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## ⚖️ Golden Rules & Anti-Patterns
|
|
24
|
+
|
|
25
|
+
| Category | ❌ NEVER DO THIS (Anti-Pattern) | ✅ ALWAYS DO THIS (Webspresso Way) |
|
|
26
|
+
| :--- | :--- | :--- |
|
|
27
|
+
| **Routing** | `routes/index.js`, `express.Router()`, `app.get('/api/users')` | `pages/api/users.get.js` exporting `{ schema, handler }` |
|
|
28
|
+
| **Database** | `db('users').where({ id }).select()` (Raw Knex) | `db.getRepository('User').findById(id)` |
|
|
29
|
+
| **Business Logic** | `controllers/UserController.js` | `services/user/get.js` → `ctx.service('user.get', input)` |
|
|
30
|
+
| **Auth** | `npm i jsonwebtoken passport express-session` | Built-in `req.auth`, `core/auth`, `middleware: ['jwt']` |
|
|
31
|
+
| **SSR Rendering**| `res.render('profile.njk', data)` inside handlers | Companion loader `pages/profile.js` exporting `async function load()` |
|
|
32
|
+
| **Exceptions** | `res.status(404).json({ error: 'Not found' })` | `throw new NotFoundError('User not found')` |
|
|
33
|
+
| **Background** | `setTimeout(...)`, `npm i bullmq` | `jobs/user/welcome.js` → `req.queue.dispatch('user.welcome', data)` |
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## 🚀 CLI Commands
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
webspresso dev # Start dev server with hot reload
|
|
41
|
+
webspresso start # Start production server
|
|
42
|
+
webspresso doctor # Run system sanity checks
|
|
43
|
+
webspresso db:migrate # Run pending Knex migrations
|
|
44
|
+
webspresso db:seed # Run database seeders
|
|
45
|
+
webspresso agents:init # Scaffold AI Agent rules and skills
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## 🏗️ Core Architecture Overview
|
|
51
|
+
|
|
52
|
+
- **`pages/` & `pages/api/`**: File-based SSR templates (`.njk`), companion data loaders (`.js`), and method-suffixed API endpoints (`.get.js`, `.post.js`).
|
|
53
|
+
- **`services/`**: Auto-discovered domain services mapping to dot-separated names (`'user.create'`).
|
|
54
|
+
- **`models/`**: Schema-driven ORM models with `zdb` definitions, repositories, relations, scopes, and query cache.
|
|
55
|
+
- **`plugins/`**: Modular extension ecosystem (Admin Panel, Media Library, Polar Billing, CSV/XLSX, Realtime, CSRF, CORS, Basic Auth).
|
|
56
|
+
- **`views/`**: Nunjucks layouts, partials, and `fsy` template helpers.
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: webspresso-orm-and-database
|
|
3
|
+
description: >-
|
|
4
|
+
Define models, run queries, handle transactions, manage relations, and scaffold migrations in
|
|
5
|
+
Webspresso ORM. Use when writing database models (defineModel), using zdb schema builders,
|
|
6
|
+
querying repositories (db.getRepository), configuring query cache, soft deletes, and ambient transactions.
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Webspresso ORM & Database Skill
|
|
10
|
+
|
|
11
|
+
⚠️ **CRITICAL ANTI-PATTERN:** Never write raw Knex table queries (e.g. `db('users')` or `knex('users')`) in application code. Always use Repositories (`db.getRepository('ModelName')`).
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 1. Defining Models (`models/*.js`)
|
|
16
|
+
|
|
17
|
+
```javascript
|
|
18
|
+
const { defineModel, zdb } = require('webspresso');
|
|
19
|
+
|
|
20
|
+
module.exports = defineModel({
|
|
21
|
+
name: 'Product',
|
|
22
|
+
table: 'products',
|
|
23
|
+
primaryKey: 'id',
|
|
24
|
+
|
|
25
|
+
schema: zdb.object({
|
|
26
|
+
id: zdb.id(),
|
|
27
|
+
name: zdb.string().min(1).max(255),
|
|
28
|
+
slug: zdb.string().unique(),
|
|
29
|
+
price: zdb.decimal({ precision: 10, scale: 2 }),
|
|
30
|
+
in_stock: zdb.boolean().default(true),
|
|
31
|
+
metadata: zdb.json().optional(),
|
|
32
|
+
user_id: zdb.foreignKey('users.id'),
|
|
33
|
+
created_at: zdb.timestamp().autoCreate(),
|
|
34
|
+
updated_at: zdb.timestamp().autoUpdate(),
|
|
35
|
+
deleted_at: zdb.timestamp().nullable(),
|
|
36
|
+
}),
|
|
37
|
+
|
|
38
|
+
relations: {
|
|
39
|
+
user: {
|
|
40
|
+
type: 'belongsTo',
|
|
41
|
+
model: 'User',
|
|
42
|
+
foreignKey: 'user_id',
|
|
43
|
+
},
|
|
44
|
+
reviews: {
|
|
45
|
+
type: 'hasMany',
|
|
46
|
+
model: 'Review',
|
|
47
|
+
foreignKey: 'product_id',
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
scopes: {
|
|
52
|
+
softDelete: true, // enables deleted_at filtering automatically
|
|
53
|
+
active: (query) => query.where('in_stock', true),
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
hidden: ['metadata.internal_notes'], // stripped from output
|
|
57
|
+
|
|
58
|
+
cache: 'auto', // query caching ('auto' | 'smart' | true)
|
|
59
|
+
|
|
60
|
+
admin: {
|
|
61
|
+
enabled: true,
|
|
62
|
+
label: 'Products',
|
|
63
|
+
icon: 'package',
|
|
64
|
+
columns: ['name', 'price', 'in_stock', 'created_at'],
|
|
65
|
+
search: ['name', 'slug'],
|
|
66
|
+
filters: ['in_stock', 'user_id'],
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## 2. Repositories API
|
|
74
|
+
|
|
75
|
+
```javascript
|
|
76
|
+
const repo = db.getRepository('Product');
|
|
77
|
+
|
|
78
|
+
// Single Record Queries
|
|
79
|
+
const item = await repo.findById(id);
|
|
80
|
+
const item = await repo.findOne({ slug: 'espresso-blend' });
|
|
81
|
+
|
|
82
|
+
// Multiple Records & Filtering
|
|
83
|
+
const activeItems = await repo.find({ in_stock: true });
|
|
84
|
+
|
|
85
|
+
// Insert & Update (auto-validates with schema & triggers lifecycle hooks)
|
|
86
|
+
const created = await repo.create({
|
|
87
|
+
name: 'Espresso Blend',
|
|
88
|
+
slug: 'espresso-blend',
|
|
89
|
+
price: 14.99,
|
|
90
|
+
user_id: user.id,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const updated = await repo.update(created.id, { price: 16.99 });
|
|
94
|
+
|
|
95
|
+
// Deletion (respects softDelete scope)
|
|
96
|
+
await repo.delete(id);
|
|
97
|
+
|
|
98
|
+
// Fluent Query Builder with Eager Loading & Pagination
|
|
99
|
+
const results = await repo.query()
|
|
100
|
+
.where('price', '>=', 10)
|
|
101
|
+
.include('user', 'reviews')
|
|
102
|
+
.orderBy('created_at', 'desc')
|
|
103
|
+
.paginate({ page: 1, limit: 20 });
|
|
104
|
+
// Returns: { data: [...], pagination: { total, page, limit, totalPages } }
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## 3. Ambient Transactions (`AsyncLocalStorage`)
|
|
110
|
+
|
|
111
|
+
Inside `db.transaction()` or services declared with `transaction: true`, all repository operations automatically bind to the ambient transaction without manual `trx` passing:
|
|
112
|
+
|
|
113
|
+
```javascript
|
|
114
|
+
await db.transaction(async () => {
|
|
115
|
+
const user = await db.getRepository('User').create(userData);
|
|
116
|
+
const account = await db.getRepository('Account').create({ userId: user.id, balance: 100 });
|
|
117
|
+
// Automatically commits if promise resolves, or rolls back if an exception is thrown
|
|
118
|
+
});
|
|
119
|
+
```
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: webspresso-routing-and-api
|
|
3
|
+
description: >-
|
|
4
|
+
Build and declare file-based API endpoints and SSR pages in Webspresso using defineApi, definePage,
|
|
5
|
+
and defineModule. Use when writing route handlers, data loaders (load()), parameter brackets ([id]),
|
|
6
|
+
Zod input validation, response streaming, and route middleware.
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Webspresso Routing & API Skill
|
|
10
|
+
|
|
11
|
+
Webspresso uses strictly file-based routing. Never use `express.Router()`, `app.get()`, or a `routes/` directory.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 1. File-Based API Endpoints (`pages/api/...`)
|
|
16
|
+
|
|
17
|
+
All API routes live in `pages/api/` and use HTTP method extensions:
|
|
18
|
+
- `pages/api/notes.get.js` → `GET /api/notes`
|
|
19
|
+
- `pages/api/notes.post.js` → `POST /api/notes`
|
|
20
|
+
- `pages/api/notes/[id].get.js` → `GET /api/notes/:id`
|
|
21
|
+
- `pages/api/notes/[id].delete.js` → `DELETE /api/notes/:id`
|
|
22
|
+
|
|
23
|
+
### Standard `defineApi` Contract
|
|
24
|
+
|
|
25
|
+
```javascript
|
|
26
|
+
const { defineApi } = require('webspresso');
|
|
27
|
+
|
|
28
|
+
module.exports = defineApi({
|
|
29
|
+
schema: ({ z }) => ({
|
|
30
|
+
body: z.object({
|
|
31
|
+
title: z.string().min(1, 'Title is required'),
|
|
32
|
+
content: z.string().optional(),
|
|
33
|
+
}),
|
|
34
|
+
params: z.object({
|
|
35
|
+
id: z.string().optional(),
|
|
36
|
+
}),
|
|
37
|
+
query: z.object({
|
|
38
|
+
filter: z.string().optional(),
|
|
39
|
+
}),
|
|
40
|
+
}),
|
|
41
|
+
middleware: ['auth'], // optional named middleware string or array of middlewares
|
|
42
|
+
handler: async (req, res, ctx) => {
|
|
43
|
+
// 1. Validated input is automatically available in req.input
|
|
44
|
+
const { title, content } = req.input.body;
|
|
45
|
+
|
|
46
|
+
// 2. Repositories and Services are injected
|
|
47
|
+
const note = await req.db.getRepository('Note').create({ title, content });
|
|
48
|
+
|
|
49
|
+
// 3. Return response
|
|
50
|
+
return res.status(201).json(note);
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## 2. SSR HTML Pages & Data Loaders (`pages/...`)
|
|
58
|
+
|
|
59
|
+
- **Template**: `pages/products/[id].njk`
|
|
60
|
+
- **Companion Loader**: `pages/products/[id].js`
|
|
61
|
+
|
|
62
|
+
### Companion Loader (`definePage`)
|
|
63
|
+
|
|
64
|
+
```javascript
|
|
65
|
+
const { definePage } = require('webspresso');
|
|
66
|
+
|
|
67
|
+
module.exports = definePage({
|
|
68
|
+
middleware: ['auth'], // optional
|
|
69
|
+
head: {
|
|
70
|
+
title: 'Product Details',
|
|
71
|
+
},
|
|
72
|
+
load: async ({ req, res, db, ctx }) => {
|
|
73
|
+
const product = await db.getRepository('Product').findById(req.params.id);
|
|
74
|
+
if (!product) {
|
|
75
|
+
throw new ctx.errors.NotFoundError('Product not found');
|
|
76
|
+
}
|
|
77
|
+
return { product, title: product.name };
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Template (`.njk`)
|
|
83
|
+
|
|
84
|
+
```nunjucks
|
|
85
|
+
{% extends "layout.njk" %}
|
|
86
|
+
|
|
87
|
+
{% block content %}
|
|
88
|
+
<div class="product-page">
|
|
89
|
+
<h1>{{ product.name }}</h1>
|
|
90
|
+
<p class="price">{{ fsy.formatCurrency(product.price) }}</p>
|
|
91
|
+
<p>{{ product.description }}</p>
|
|
92
|
+
</div>
|
|
93
|
+
{% endblock %}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## 3. Modular Domain Architecture (`defineModule`)
|
|
99
|
+
|
|
100
|
+
Encapsulate feature domains inside `src/modules/` or `modules/`:
|
|
101
|
+
|
|
102
|
+
```javascript
|
|
103
|
+
const { defineModule } = require('webspresso');
|
|
104
|
+
|
|
105
|
+
module.exports = defineModule({
|
|
106
|
+
name: 'billing',
|
|
107
|
+
pages: './pages', // auto-discovers pages under /billing/*
|
|
108
|
+
api: './api', // auto-discovers API under /api/billing/*
|
|
109
|
+
services: './services', // auto-discovers services under 'billing.*'
|
|
110
|
+
middlewares: {
|
|
111
|
+
paidOnly: (req, res, next) => { ... },
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
```
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: webspresso-services
|
|
3
|
+
description: >-
|
|
4
|
+
Encapsulate business logic, validations, transactions, and authorizations in Webspresso services.
|
|
5
|
+
Use when writing service files (services/domain/action.js), defining schemas (defineService),
|
|
6
|
+
configuring transaction: true, calling ctx.service() or req.service(), and adding RBAC guards.
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Webspresso Services Layer Skill
|
|
10
|
+
|
|
11
|
+
⚠️ **CRITICAL ANTI-PATTERN:** Never create a `controllers/` folder or controller classes. Encapsulate multi-step business logic in `services/` and invoke via `ctx.service()` or `req.service()`.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 1. File-Based Auto-Discovery (`services/`)
|
|
16
|
+
|
|
17
|
+
Files in `services/` map directly to dot-separated service names:
|
|
18
|
+
- `services/user/create.js` → `'user.create'`
|
|
19
|
+
- `services/order/checkout.js` → `'order.checkout'`
|
|
20
|
+
- `services/billing/process-refund.js` → `'billing.process-refund'` (and `'billing.processRefund'`)
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## 2. Defining Services (`defineService`)
|
|
25
|
+
|
|
26
|
+
```javascript
|
|
27
|
+
const { defineService } = require('webspresso');
|
|
28
|
+
|
|
29
|
+
module.exports = defineService({
|
|
30
|
+
schema: ({ z }) => z.object({
|
|
31
|
+
userId: z.number().int().positive(),
|
|
32
|
+
amount: z.number().positive(),
|
|
33
|
+
reason: z.string().min(3),
|
|
34
|
+
}),
|
|
35
|
+
|
|
36
|
+
// Optional Declarative Auth / Role Guard
|
|
37
|
+
auth: ['admin', 'manager'], // or boolean true, or (ctx, input) => boolean
|
|
38
|
+
|
|
39
|
+
// Automatic ACID Transaction Propagation
|
|
40
|
+
transaction: true,
|
|
41
|
+
|
|
42
|
+
// Optional Service Result Cache
|
|
43
|
+
cache: {
|
|
44
|
+
ttl: 300, // seconds
|
|
45
|
+
key: (input) => `refund:${input.userId}:${input.amount}`,
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
// Execution Handler
|
|
49
|
+
handler: async (input, ctx) => {
|
|
50
|
+
// ctx.db, ctx.service, ctx.user, ctx.logger are automatically injected
|
|
51
|
+
const userRepo = ctx.db.getRepository('User');
|
|
52
|
+
const accountRepo = ctx.db.getRepository('Account');
|
|
53
|
+
|
|
54
|
+
const user = await userRepo.findById(input.userId);
|
|
55
|
+
if (!user) {
|
|
56
|
+
throw new ctx.errors.NotFoundError('User not found');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const account = await accountRepo.findOne({ userId: user.id });
|
|
60
|
+
const updatedAccount = await accountRepo.update(account.id, {
|
|
61
|
+
balance: account.balance + input.amount,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Nested service call shares the same ambient transaction automatically
|
|
65
|
+
await ctx.service('audit.log', {
|
|
66
|
+
action: 'refund.processed',
|
|
67
|
+
userId: user.id,
|
|
68
|
+
amount: input.amount,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
success: true,
|
|
73
|
+
balance: updatedAccount.balance,
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## 3. Invoking Services
|
|
82
|
+
|
|
83
|
+
From SSR Companion Loaders:
|
|
84
|
+
```javascript
|
|
85
|
+
module.exports = definePage({
|
|
86
|
+
load: async ({ ctx }) => {
|
|
87
|
+
const data = await ctx.service('order.summary', { userId: ctx.user.id });
|
|
88
|
+
return { data };
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
From API Route Handlers:
|
|
94
|
+
```javascript
|
|
95
|
+
module.exports = defineApi({
|
|
96
|
+
handler: async (req, res) => {
|
|
97
|
+
const result = await req.service('order.checkout', req.input.body);
|
|
98
|
+
return res.status(200).json(result);
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
```
|
|
@@ -92,7 +92,8 @@ function registerCommand(program) {
|
|
|
92
92
|
|
|
93
93
|
// Hash the password (same rounds as admin panel setup)
|
|
94
94
|
const { hash } = require('../../core/auth/hash');
|
|
95
|
-
const
|
|
95
|
+
const rounds = process.env.NODE_ENV === 'test' ? 4 : 10;
|
|
96
|
+
const hashedPassword = await hash(password, rounds);
|
|
96
97
|
|
|
97
98
|
// Update the password
|
|
98
99
|
await db('admin_users')
|
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
const fs = require('fs');
|
|
7
7
|
const path = require('path');
|
|
8
8
|
const { loadDbConfig } = require('../utils/db');
|
|
9
|
-
const { generateAdminUsersMigration } = require('../../plugins/admin-panel/migration-template');
|
|
10
9
|
|
|
11
10
|
function registerCommand(program) {
|
|
12
11
|
program
|
|
@@ -53,6 +52,7 @@ function registerCommand(program) {
|
|
|
53
52
|
const filepath = path.join(migrationDir, filename);
|
|
54
53
|
|
|
55
54
|
// Generate migration content
|
|
55
|
+
const { generateAdminUsersMigration } = require('../../plugins/admin-panel/migration-template');
|
|
56
56
|
const content = generateAdminUsersMigration();
|
|
57
57
|
|
|
58
58
|
// Write migration file
|
|
@@ -39,6 +39,40 @@ function copyFileSafe(src, dest, force = false) {
|
|
|
39
39
|
return destExists ? 'overwritten' : 'created';
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Recursively copies a directory safely.
|
|
44
|
+
* @param {string} srcDir
|
|
45
|
+
* @param {string} destDir
|
|
46
|
+
* @param {boolean} force
|
|
47
|
+
* @param {string[]} installed
|
|
48
|
+
* @param {string[]} skipped
|
|
49
|
+
* @param {string} baseRel
|
|
50
|
+
*/
|
|
51
|
+
function copyDirSafe(srcDir, destDir, force = false, installed = [], skipped = [], baseRel = '') {
|
|
52
|
+
if (!fs.existsSync(srcDir)) return;
|
|
53
|
+
if (!fs.existsSync(destDir)) {
|
|
54
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const entries = fs.readdirSync(srcDir, { withFileTypes: true });
|
|
58
|
+
for (const entry of entries) {
|
|
59
|
+
const srcPath = path.join(srcDir, entry.name);
|
|
60
|
+
const destPath = path.join(destDir, entry.name);
|
|
61
|
+
const relPath = path.join(baseRel, entry.name);
|
|
62
|
+
|
|
63
|
+
if (entry.isDirectory()) {
|
|
64
|
+
copyDirSafe(srcPath, destPath, force, installed, skipped, relPath);
|
|
65
|
+
} else {
|
|
66
|
+
const status = copyFileSafe(srcPath, destPath, force);
|
|
67
|
+
if (status === 'created' || status === 'overwritten') {
|
|
68
|
+
installed.push(relPath);
|
|
69
|
+
} else {
|
|
70
|
+
skipped.push(relPath);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
42
76
|
/**
|
|
43
77
|
* Scaffolds AI Agent rules and guidebook files into target project directory.
|
|
44
78
|
* @param {string} targetDir
|
|
@@ -98,23 +132,29 @@ function scaffoldAgentsFiles(targetDir, options = {}) {
|
|
|
98
132
|
}
|
|
99
133
|
}
|
|
100
134
|
|
|
101
|
-
// 4. .agents/*.md Guidebook directory
|
|
135
|
+
// 4. .agents/*.md Guidebook and skills/ directory
|
|
102
136
|
if (includeGuides && fs.existsSync(SOURCE_AGENTS_DIR)) {
|
|
103
137
|
const destAgentsDir = path.join(targetDir, '.agents');
|
|
104
138
|
if (!fs.existsSync(destAgentsDir)) {
|
|
105
139
|
fs.mkdirSync(destAgentsDir, { recursive: true });
|
|
106
140
|
}
|
|
107
141
|
|
|
108
|
-
const
|
|
109
|
-
for (const
|
|
110
|
-
|
|
111
|
-
const
|
|
112
|
-
const
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
142
|
+
const entries = fs.readdirSync(SOURCE_AGENTS_DIR, { withFileTypes: true });
|
|
143
|
+
for (const entry of entries) {
|
|
144
|
+
if (entry.name === 'internal') continue; // exclude internal reviews from user projects
|
|
145
|
+
const srcPath = path.join(SOURCE_AGENTS_DIR, entry.name);
|
|
146
|
+
const destPath = path.join(destAgentsDir, entry.name);
|
|
147
|
+
const relPath = path.join('.agents', entry.name);
|
|
148
|
+
|
|
149
|
+
if (entry.isDirectory()) {
|
|
150
|
+
copyDirSafe(srcPath, destPath, force, result.installed, result.skipped, relPath);
|
|
151
|
+
} else if (entry.name.endsWith('.md')) {
|
|
152
|
+
const status = copyFileSafe(srcPath, destPath, force);
|
|
153
|
+
if (status === 'created' || status === 'overwritten') {
|
|
154
|
+
result.installed.push(relPath);
|
|
155
|
+
} else {
|
|
156
|
+
result.skipped.push(relPath);
|
|
157
|
+
}
|
|
118
158
|
}
|
|
119
159
|
}
|
|
120
160
|
}
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
const { loadDbConfig, createDbInstance } = require('../utils/db');
|
|
6
|
-
const { purgeAuditLogs } = require('../../plugins/audit-log/purge');
|
|
7
6
|
|
|
8
7
|
function registerCommand(program) {
|
|
9
8
|
program
|
|
@@ -28,6 +27,7 @@ function registerCommand(program) {
|
|
|
28
27
|
const olderThan = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
|
29
28
|
|
|
30
29
|
try {
|
|
30
|
+
const { purgeAuditLogs } = require('../../plugins/audit-log/purge');
|
|
31
31
|
const deleted = await purgeAuditLogs(knex, {
|
|
32
32
|
tableName: options.table,
|
|
33
33
|
olderThan,
|
package/bin/commands/orm-map.js
CHANGED
|
@@ -6,10 +6,6 @@ const fs = require('fs');
|
|
|
6
6
|
const os = require('os');
|
|
7
7
|
const path = require('path');
|
|
8
8
|
const { execFileSync } = require('child_process');
|
|
9
|
-
const { getWebspressoOrmForProject } = require('../utils/resolve-webspresso-orm');
|
|
10
|
-
const { loadProjectModels } = require('../utils/orm-map-load');
|
|
11
|
-
const { buildSnapshot, buildMermaidErDiagram } = require('../utils/orm-map-snapshot');
|
|
12
|
-
const { buildOrmMapHtml, readPackageName } = require('../utils/orm-map-html');
|
|
13
9
|
|
|
14
10
|
function ensureOpenInsideTrustedRoots(absFile, cwd) {
|
|
15
11
|
if (!fs.existsSync(absFile)) {
|
|
@@ -72,6 +68,11 @@ function registerCommand(program) {
|
|
|
72
68
|
.option('-e, --env <environment>', 'Config environment', 'development')
|
|
73
69
|
.option('-m, --models <dir>', 'Models directory (overrides config default ./models)')
|
|
74
70
|
.action((options) => {
|
|
71
|
+
const { getWebspressoOrmForProject } = require('../utils/resolve-webspresso-orm');
|
|
72
|
+
const { loadProjectModels } = require('../utils/orm-map-load');
|
|
73
|
+
const { buildSnapshot, buildMermaidErDiagram } = require('../utils/orm-map-snapshot');
|
|
74
|
+
const { buildOrmMapHtml, readPackageName } = require('../utils/orm-map-html');
|
|
75
|
+
|
|
75
76
|
const cwd = process.cwd();
|
|
76
77
|
const { modelsDir, loaded, errors } = loadProjectModels(cwd, {
|
|
77
78
|
modelsOverride: options.models,
|
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
const fs = require('fs');
|
|
8
8
|
const path = require('path');
|
|
9
9
|
const { loadDbConfig } = require('../utils/db');
|
|
10
|
-
const { generatePolarMigration } = require('../../plugins/polar/src/migration');
|
|
11
10
|
|
|
12
11
|
function registerCommand(program) {
|
|
13
12
|
program
|
|
@@ -40,6 +39,7 @@ function registerCommand(program) {
|
|
|
40
39
|
filepath = path.join(migrationDir, `${timestamp}_polar_billing.js`);
|
|
41
40
|
}
|
|
42
41
|
|
|
42
|
+
const { generatePolarMigration } = require('../../plugins/polar/src/migration');
|
|
43
43
|
const content = generatePolarMigration({ tableName: options.table });
|
|
44
44
|
fs.writeFileSync(filepath, content, 'utf8');
|
|
45
45
|
console.log(`✅ Polar migration written: ${filepath}`);
|