@vevedh/create-feathers-app 0.1.0-rc.4 → 0.1.0-rc.6
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 +7 -7
- package/bin/create-feathers-app.mjs +2 -1
- package/lib/compose.mjs +248 -0
- package/lib/scaffold.mjs +37 -14
- package/lib/secure-runtime/connect.mjs +28 -0
- package/lib/secure-runtime/policy.mjs +85 -0
- package/lib/secure-runtime/prepare-storage.mjs +14 -0
- package/lib/secure-runtime/providers.mjs +74 -0
- package/lib/secure-runtime/register.mjs +80 -0
- package/lib/secure-runtime/storage.mjs +31 -0
- package/lib/services.mjs +163 -46
- package/lib/typescript.mjs +148 -0
- package/package.json +2 -2
- package/templates/daisyui-ssg/package.json +6 -6
- package/templates/daisyui-ssr/package.json +6 -6
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Générez des applications FeathersJS v5 avec Vue 3, Pinia, Vite, Quasar 2 ou Tailwind CSS + daisyUI.
|
|
4
4
|
|
|
5
|
-
Le CLI est indépendant du runtime [`@vevedh/vite-plugin-feathersjs`](https://www.npmjs.com/package/@vevedh/vite-plugin-feathersjs) (version 0.2.
|
|
5
|
+
Le CLI est indépendant du runtime [`@vevedh/vite-plugin-feathersjs`](https://www.npmjs.com/package/@vevedh/vite-plugin-feathersjs) (version 0.2.7). La version `0.1.0-rc.5` est la candidate présente dans les sources locales. Ne pas présumer de sa publication npm ; vérifier les versions du registre avant installation. La PWA reste optionnelle pour une SPA.
|
|
6
6
|
|
|
7
7
|
## Usage
|
|
8
8
|
|
|
@@ -10,13 +10,13 @@ Le CLI est indépendant du runtime [`@vevedh/vite-plugin-feathersjs`](https://ww
|
|
|
10
10
|
# Préversion disponible sur npm : 0.1.0-rc.1
|
|
11
11
|
npm exec --yes --package=@vevedh/create-feathers-app@0.1.0-rc.1 -- create-feathers-app portail --ui daisyui --render spa
|
|
12
12
|
|
|
13
|
-
#
|
|
13
|
+
# Candidate RC5, depuis les sources mises en stage :
|
|
14
14
|
node .release/create-feathers-app/bin/create-feathers-app.mjs portail-ssr --ui quasar2 --render ssr
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
| Interface | SPA | SSR | SSG |
|
|
18
18
|
| --- | --- | --- | --- |
|
|
19
|
-
| Quasar 2 | Générable | Générable, validation Windows
|
|
19
|
+
| Quasar 2 | Générable | Générable, validation Windows complète à vérifier | Bloqué |
|
|
20
20
|
| daisyUI 5 | Générable | Générable | Générable |
|
|
21
21
|
|
|
22
22
|
## PWA optionnelle (SPA uniquement)
|
|
@@ -40,7 +40,7 @@ Le générateur ajoute le module `api/services/tickets` et son inscription dans
|
|
|
40
40
|
|
|
41
41
|
## Profils d'identité et de bases
|
|
42
42
|
|
|
43
|
-
La création de projet accepte `--auth none|jwt|oauth2|oidc|keycloak`, `--schema typebox|json|zod` et `--db main:memory,audit:mongodb` avec alias nommés. **Les
|
|
43
|
+
La création de projet accepte `--auth none|jwt|oauth2|oidc|keycloak`, `--schema typebox|json|zod` et `--db main:memory,audit:mongodb` avec alias nommés. **Les profils JWT disposent de modules Feathers/CASL et les providers nommés de modules serveur ; le bootstrap HTTP sécurisé et les tests DB restent à certifier. Les autres fournisseurs externes sont des profils de configuration, pas des intégrations opérationnelles.** L'API échoue volontairement au démarrage jusqu'au raccordement et à la certification du fournisseur ou du SGBD.
|
|
44
44
|
|
|
45
45
|
Les projets sans authentification sont exclusivement des démonstrations locales. Ils ne doivent jamais être exposés tels quels sur Internet. Aucun secret, mot de passe ou jeton n'est inclus dans les templates.
|
|
46
46
|
|
|
@@ -57,10 +57,10 @@ SPA et daisyUI SSR/SSG utilisent `http://127.0.0.1:5173`. Quasar SSR utilise `ht
|
|
|
57
57
|
|
|
58
58
|
La documentation publique VitePress figure dans `docs/cli` du dépôt et sera déployée sur GitHub Pages après activation du workflow de publication.
|
|
59
59
|
|
|
60
|
-
## API Feathers intégrée à Vite en développement (
|
|
60
|
+
## API Feathers intégrée à Vite en développement (RC5)
|
|
61
61
|
|
|
62
62
|
Pour les applications SPA, `--dev-api vite` est désormais la valeur par défaut.
|
|
63
|
-
Le plugin public `@vevedh/vite-plugin-feathersjs@0.2.
|
|
63
|
+
Le plugin public `@vevedh/vite-plugin-feathersjs@0.2.7` est chargé depuis
|
|
64
64
|
`vite.config.ts` et monte les services sur `/api` pendant `vite dev`.
|
|
65
65
|
Il n'y a pas de second serveur REST ni de proxy REST à démarrer.
|
|
66
66
|
|
|
@@ -77,4 +77,4 @@ node .release/create-feathers-app/bin/create-feathers-app.mjs mon-spa --ui quasa
|
|
|
77
77
|
**Production** : le plugin Vite s'exécute uniquement en développement.
|
|
78
78
|
Le serveur `api/server.mjs` autonome reste disponible et obligatoire pour
|
|
79
79
|
le déploiement ; `vite preview` n'héberge pas l'API.
|
|
80
|
-
|
|
80
|
+
La version `0.2.7` du plugin doit être certifiée et publiée avant une installation depuis npm.
|
|
@@ -18,6 +18,7 @@ Usage: create-feathers-app <name> [options]
|
|
|
18
18
|
--adapter <koa|express> Default koa
|
|
19
19
|
--transport <rest|both> Default rest (both only for SPA)
|
|
20
20
|
--pwa <on|off> Optional offline SPA shell (default off, no API caching)
|
|
21
|
+
--compose <off|dev|prod|both> Generate Docker Compose for SPA (default off)
|
|
21
22
|
--dev-api <vite|separate> SPA default vite; SSR/SSG always separate
|
|
22
23
|
--dry-run Preview files without writing
|
|
23
24
|
--json Machine-readable output
|
|
@@ -26,7 +27,7 @@ Usage: create-feathers-app <name> [options]
|
|
|
26
27
|
--db <alias:adapter[,alias:adapter]> Named connections (default main:memory)
|
|
27
28
|
--version Print CLI version
|
|
28
29
|
|
|
29
|
-
Service: create-feathers-app service add <name> [--project <path>] [--schema typebox|json|zod] [--db <alias>] [--auth none|jwt] [--dry-run]
|
|
30
|
+
Service: create-feathers-app service add <name> [--project <path>] [--schema typebox|json|zod] [--db <alias>] [--auth none|jwt] [--lang js|ts] [--dry-run]
|
|
30
31
|
|
|
31
32
|
Available: Quasar 2 SPA/SSR; daisyUI SPA, SSR, SSG.
|
|
32
33
|
Quasar SSR uses the R16-certified architecture; R17-generated SSR requires its own Windows E2E certification. Quasar SSG not supported.`)
|
package/lib/compose.mjs
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/** Opt-in, loopback-only Docker Compose profiles for Vite-first SPA applications.
|
|
2
|
+
* Authentication and database capability guards from scaffold.mjs stay active.
|
|
3
|
+
*/
|
|
4
|
+
const composeModes = new Set(['dev', 'prod', 'both'])
|
|
5
|
+
|
|
6
|
+
function requireExact(files, path, before, after) {
|
|
7
|
+
const source = files.get(path)
|
|
8
|
+
if (typeof source !== 'string' || source.split(before).length !== 2) {
|
|
9
|
+
throw new Error(`Unexpected ${path} template; refusing Docker generation`)
|
|
10
|
+
}
|
|
11
|
+
files.set(path, source.replace(before, after))
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function composeReadme(options) {
|
|
15
|
+
const dev = options.compose === 'dev' || options.compose === 'both'
|
|
16
|
+
const prod = options.compose === 'prod' || options.compose === 'both'
|
|
17
|
+
return `# Docker Compose — ${options.name}
|
|
18
|
+
|
|
19
|
+
Ces fichiers déploient le squelette Vite-first, **pas une application déjà sécurisée pour Internet**.
|
|
20
|
+
Le service de démonstration \`notes\` est en mémoire et sans authentification. Les ports HTTP sont publiés sur \`127.0.0.1\` uniquement.
|
|
21
|
+
Aucun port de base de données n'est exposé. Le générateur **n'active pas** les profils JWT, OAuth ou bases externes : leurs garde-fous existants continuent de refuser le démarrage tant que leur intégration n'est pas certifiée.
|
|
22
|
+
|
|
23
|
+
## Prérequis
|
|
24
|
+
|
|
25
|
+
- Docker Desktop avec Docker Compose v2.
|
|
26
|
+
- Node.js compatible et Corepack/pnpm pour produire \`pnpm-lock.yaml\`.
|
|
27
|
+
- Vérifier les dépendances, leurs licences et vulnérabilités avant tout déploiement.
|
|
28
|
+
|
|
29
|
+
${dev ? `## Développement
|
|
30
|
+
|
|
31
|
+
\`docker compose -f compose.dev.yaml up --build\`
|
|
32
|
+
|
|
33
|
+
Ouvrir http://127.0.0.1:5173. Le serveur Vite monte Feathers via le plugin Vite, sans lancer un deuxième processus d'API. Aucun autre port n'est publié.
|
|
34
|
+
|
|
35
|
+
Arrêt : \`docker compose -f compose.dev.yaml down\` (le volume de dépendances est conservé).
|
|
36
|
+
|
|
37
|
+
` : ''}${prod ? `## Image de déploiement (prototype isolé)
|
|
38
|
+
|
|
39
|
+
Créer et réviser le lockfile à la racine du projet :
|
|
40
|
+
|
|
41
|
+
\`corepack pnpm install --lockfile-only\`
|
|
42
|
+
|
|
43
|
+
\`docker compose -f compose.prod.yaml up --build -d\`
|
|
44
|
+
|
|
45
|
+
Ouvrir http://127.0.0.1:8080. Nginx sert la SPA et relaie \`/api/\` à Feathers et ${options.transport === 'both' ? '\`/socket.io/\` au serveur temps réel.' : 'aucun Socket.IO (REST uniquement).'} Le backend n'a pas de port publié sur l'hôte.
|
|
46
|
+
|
|
47
|
+
Arrêt : \`docker compose -f compose.prod.yaml down\`.
|
|
48
|
+
|
|
49
|
+
**Avant une réelle mise en production :** remplacer le service notes non protégé, activer JWT/RBAC et une persistance durable, vérifier les migrations, isoler les secrets, mettre en place le reverse proxy HTTPS et la politique d'origines, vérifier les journaux et sauvegardes et exécuter les E2E REST/Socket.IO de la cible. Ne pas changer l'interface hôte de \`127.0.0.1\` vers \`0.0.0.0\` sans ces contrôles.
|
|
50
|
+
|
|
51
|
+
` : ''}## Particularités
|
|
52
|
+
|
|
53
|
+
- Choix générés : ${options.ui} SPA, ${options.adapter}, ${options.transport}, PWA ${options.pwa}.
|
|
54
|
+
- Le serveur API autonome peut écouter sur toutes les interfaces **du conteneur** uniquement via \`FEATHERS_BIND_HOST=0.0.0.0\` ; le réseau \`backend\` est interne et non publié.
|
|
55
|
+
- Le mode PWA, lorsqu'il est sélectionné, ne doit jamais mettre \`/api/\` ou \`/socket.io/\` en cache.
|
|
56
|
+
- Les images de production sont construites depuis un lockfile audité ; ne jamais copier de \`.env\` réel dans les images.
|
|
57
|
+
- Les ports peuvent être personnalisés uniquement côté loopback via \`P4_DEV_PORT\` et \`P4_HTTP_PORT\`.
|
|
58
|
+
`
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function devCompose(options) {
|
|
62
|
+
return `name: ${options.name}-dev
|
|
63
|
+
services:
|
|
64
|
+
app:
|
|
65
|
+
image: node:24-bookworm-slim
|
|
66
|
+
working_dir: /workspace
|
|
67
|
+
command: ["sh", "-c", "corepack pnpm install --no-frozen-lockfile && corepack pnpm exec vite --host 0.0.0.0 --port 5173 --strictPort"]
|
|
68
|
+
environment:
|
|
69
|
+
NODE_ENV: development
|
|
70
|
+
VITE_FEATHERS_REALTIME: "${options.transport === 'both' ? '1' : '0'}"
|
|
71
|
+
P4_DEV_PORT: "\${P4_DEV_PORT:-5173}"
|
|
72
|
+
volumes:
|
|
73
|
+
- .:/workspace
|
|
74
|
+
- dependencies:/workspace/node_modules
|
|
75
|
+
ports:
|
|
76
|
+
- "127.0.0.1:\${P4_DEV_PORT:-5173}:5173"
|
|
77
|
+
security_opt:
|
|
78
|
+
- no-new-privileges:true
|
|
79
|
+
volumes:
|
|
80
|
+
dependencies:
|
|
81
|
+
`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function prodCompose(options) {
|
|
85
|
+
return `name: ${options.name}-prod
|
|
86
|
+
services:
|
|
87
|
+
api:
|
|
88
|
+
build:
|
|
89
|
+
context: .
|
|
90
|
+
dockerfile: infra/compose/Dockerfile.api
|
|
91
|
+
environment:
|
|
92
|
+
NODE_ENV: production
|
|
93
|
+
FEATHERS_BIND_HOST: "0.0.0.0"
|
|
94
|
+
FEATHERS_ADAPTER: "${options.adapter}"
|
|
95
|
+
FEATHERS_SOCKETIO: "${options.transport === 'both' ? '1' : '0'}"
|
|
96
|
+
FEATHERS_API_PORT: "3030"
|
|
97
|
+
FEATHERS_SOCKET_PORT: "3031"
|
|
98
|
+
FEATHERS_PUBLIC_ORIGIN: "http://127.0.0.1:\${P4_HTTP_PORT:-8080}"
|
|
99
|
+
FEATHERS_PREVIEW_ORIGIN: "http://127.0.0.1:\${P4_HTTP_PORT:-8080}"
|
|
100
|
+
expose:
|
|
101
|
+
- "3030"
|
|
102
|
+
${options.transport === 'both' ? ' - "3031"\n' : ''} networks:
|
|
103
|
+
- backend
|
|
104
|
+
security_opt:
|
|
105
|
+
- no-new-privileges:true
|
|
106
|
+
read_only: true
|
|
107
|
+
tmpfs:
|
|
108
|
+
- /tmp
|
|
109
|
+
healthcheck:
|
|
110
|
+
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3030/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
|
111
|
+
interval: 10s
|
|
112
|
+
timeout: 5s
|
|
113
|
+
retries: 8
|
|
114
|
+
start_period: 20s
|
|
115
|
+
web:
|
|
116
|
+
build:
|
|
117
|
+
context: .
|
|
118
|
+
dockerfile: infra/compose/Dockerfile.web
|
|
119
|
+
depends_on:
|
|
120
|
+
api:
|
|
121
|
+
condition: service_healthy
|
|
122
|
+
ports:
|
|
123
|
+
- "127.0.0.1:\${P4_HTTP_PORT:-8080}:8080"
|
|
124
|
+
networks:
|
|
125
|
+
- frontend
|
|
126
|
+
- backend
|
|
127
|
+
security_opt:
|
|
128
|
+
- no-new-privileges:true
|
|
129
|
+
networks:
|
|
130
|
+
frontend:
|
|
131
|
+
backend:
|
|
132
|
+
internal: true
|
|
133
|
+
`
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const apiDockerfile = `FROM node:24-bookworm-slim
|
|
137
|
+
WORKDIR /app
|
|
138
|
+
RUN corepack enable
|
|
139
|
+
COPY package.json pnpm-lock.yaml ./
|
|
140
|
+
RUN corepack pnpm install --frozen-lockfile --prod
|
|
141
|
+
COPY api ./api
|
|
142
|
+
USER node
|
|
143
|
+
EXPOSE 3030 3031
|
|
144
|
+
CMD ["node", "api/server.mjs"]
|
|
145
|
+
`
|
|
146
|
+
|
|
147
|
+
const webDockerfile = `FROM node:24-bookworm-slim AS builder
|
|
148
|
+
WORKDIR /app
|
|
149
|
+
RUN corepack enable
|
|
150
|
+
COPY package.json pnpm-lock.yaml ./
|
|
151
|
+
RUN corepack pnpm install --frozen-lockfile
|
|
152
|
+
COPY . .
|
|
153
|
+
RUN corepack pnpm run build
|
|
154
|
+
|
|
155
|
+
FROM nginx:stable-alpine
|
|
156
|
+
COPY infra/compose/nginx.conf /etc/nginx/conf.d/default.conf
|
|
157
|
+
COPY --from=builder /app/dist /usr/share/nginx/html
|
|
158
|
+
EXPOSE 8080
|
|
159
|
+
`
|
|
160
|
+
|
|
161
|
+
function nginx(options) {
|
|
162
|
+
return `server {
|
|
163
|
+
listen 8080;
|
|
164
|
+
server_name _;
|
|
165
|
+
root /usr/share/nginx/html;
|
|
166
|
+
index index.html;
|
|
167
|
+
autoindex off;
|
|
168
|
+
server_tokens off;
|
|
169
|
+
add_header X-Content-Type-Options nosniff always;
|
|
170
|
+
add_header Referrer-Policy no-referrer always;
|
|
171
|
+
add_header X-Frame-Options DENY always;
|
|
172
|
+
location = /api { return 404; }
|
|
173
|
+
location /api/ {
|
|
174
|
+
proxy_pass http://api:3030/api/;
|
|
175
|
+
proxy_http_version 1.1;
|
|
176
|
+
proxy_set_header Host $host;
|
|
177
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
178
|
+
proxy_set_header X-Forwarded-For $remote_addr;
|
|
179
|
+
proxy_read_timeout 30s;
|
|
180
|
+
}
|
|
181
|
+
${options.transport === 'both' ? ` location /socket.io/ {
|
|
182
|
+
proxy_pass http://api:3031;
|
|
183
|
+
proxy_http_version 1.1;
|
|
184
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
185
|
+
proxy_set_header Connection "upgrade";
|
|
186
|
+
proxy_set_header Host $host;
|
|
187
|
+
proxy_read_timeout 65s;
|
|
188
|
+
}
|
|
189
|
+
` : ` location /socket.io/ { return 404; }
|
|
190
|
+
`} location / {
|
|
191
|
+
try_files $uri $uri/ /index.html;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
`
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const dockerIgnore = `node_modules
|
|
198
|
+
.git
|
|
199
|
+
.agents
|
|
200
|
+
.ai
|
|
201
|
+
.codex
|
|
202
|
+
.cursor
|
|
203
|
+
.claude
|
|
204
|
+
.gemini
|
|
205
|
+
.windsurf
|
|
206
|
+
.mcp.json
|
|
207
|
+
.env
|
|
208
|
+
.env.*
|
|
209
|
+
!.env.example
|
|
210
|
+
database.env
|
|
211
|
+
*.zip
|
|
212
|
+
*.log
|
|
213
|
+
coverage
|
|
214
|
+
dist
|
|
215
|
+
.vite
|
|
216
|
+
.quasar
|
|
217
|
+
docs-private
|
|
218
|
+
test-results
|
|
219
|
+
playwright-report
|
|
220
|
+
`
|
|
221
|
+
|
|
222
|
+
export function enableCompose(files, options) {
|
|
223
|
+
if (!composeModes.has(options.compose) || options.render !== 'spa') {
|
|
224
|
+
throw new Error('Compose generation supports only SPA and compose dev|prod|both')
|
|
225
|
+
}
|
|
226
|
+
const dev = options.compose === 'dev' || options.compose === 'both'
|
|
227
|
+
const prod = options.compose === 'prod' || options.compose === 'both'
|
|
228
|
+
if (dev && options.devApi !== 'vite') {
|
|
229
|
+
throw new Error('Compose development requires --dev-api vite for a single Feathers/Vite listener')
|
|
230
|
+
}
|
|
231
|
+
const created = new Map([['COMPOSE.md', composeReadme(options)]])
|
|
232
|
+
if (dev) created.set('compose.dev.yaml', devCompose(options))
|
|
233
|
+
if (prod) {
|
|
234
|
+
created.set('compose.prod.yaml', prodCompose(options))
|
|
235
|
+
created.set('infra/compose/Dockerfile.api', apiDockerfile)
|
|
236
|
+
created.set('infra/compose/Dockerfile.web', webDockerfile)
|
|
237
|
+
created.set('infra/compose/nginx.conf', nginx(options))
|
|
238
|
+
// The only Docker interface bound to all container addresses is the private API listener.
|
|
239
|
+
requireExact(files, 'api/server.mjs', "const realtime = process.env.FEATHERS_SOCKETIO === '1'", "const bindHost = process.env.FEATHERS_BIND_HOST === '0.0.0.0' ? '0.0.0.0' : '127.0.0.1'\nconst realtime = process.env.FEATHERS_SOCKETIO === '1'")
|
|
240
|
+
requireExact(files, 'api/server.mjs', "host: '127.0.0.1' } : false", 'host: bindHost } : false')
|
|
241
|
+
requireExact(files, 'api/server.mjs', "httpServer.listen(port, '127.0.0.1', resolve)", 'httpServer.listen(port, bindHost, resolve)')
|
|
242
|
+
}
|
|
243
|
+
created.set('.dockerignore', dockerIgnore)
|
|
244
|
+
for (const [key, content] of created) {
|
|
245
|
+
if (files.has(key)) throw new Error(`Compose file conflicts with a template: ${key}`)
|
|
246
|
+
files.set(key, content)
|
|
247
|
+
}
|
|
248
|
+
}
|
package/lib/scaffold.mjs
CHANGED
|
@@ -2,8 +2,9 @@ import { mkdir, lstat, readdir, readFile, writeFile } from 'node:fs/promises'
|
|
|
2
2
|
import { existsSync } from 'node:fs'
|
|
3
3
|
import { dirname, join, relative, resolve, sep } from 'node:path'
|
|
4
4
|
import { buildProjectPlan } from './planner/plan.mjs'
|
|
5
|
+
import { enableCompose } from './compose.mjs'
|
|
5
6
|
|
|
6
|
-
const KEYS = new Set(['--name', '--dir', '--ui', '--render', '--adapter', '--transport', '--dry-run', '--json', '--schema', '--auth', '--db', '--pwa', '--dev-api'])
|
|
7
|
+
const KEYS = new Set(['--name', '--dir', '--ui', '--render', '--adapter', '--transport', '--dry-run', '--json', '--schema', '--auth', '--db', '--pwa', '--dev-api', '--compose'])
|
|
7
8
|
const CASES = Object.freeze({
|
|
8
9
|
'quasar2:spa': 'quasar-spa',
|
|
9
10
|
'quasar2:ssr': 'quasar-ssr',
|
|
@@ -43,8 +44,8 @@ function envChange(text, options) {
|
|
|
43
44
|
}
|
|
44
45
|
function spaManifest(o) {
|
|
45
46
|
const dependencies = {
|
|
46
|
-
'@vevedh/vite-plugin-feathersjs':'0.2.
|
|
47
|
-
'@feathersjs/koa':'
|
|
47
|
+
'@vevedh/vite-plugin-feathersjs':'0.2.7', '@feathersjs/feathers':'5.0.50', '@feathersjs/errors':'5.0.50',
|
|
48
|
+
'@feathersjs/koa':'5.0.51', '@feathersjs/express':'5.0.51', '@feathersjs/socketio':'5.0.51',
|
|
48
49
|
vue:'^3.5.44', pinia:'^3.0.4', 'socket.io-client':'^4.8.4',
|
|
49
50
|
}
|
|
50
51
|
const devDependencies = {
|
|
@@ -87,13 +88,14 @@ function enableSpaViteRuntime(files, options) {
|
|
|
87
88
|
}
|
|
88
89
|
const realtime = options.transport === 'both'
|
|
89
90
|
const socket = realtime
|
|
90
|
-
? "{ port: 3031, host: '127.0.0.1', origins: ['http://127.0.0.1:5173
|
|
91
|
+
? "{ port: 3031, host: '127.0.0.1', origins: ['http://127.0.0.1:' + String(Number(process.env.P4_DEV_PORT || 5173))] }"
|
|
91
92
|
: 'false'
|
|
92
93
|
const runtime = `feathers({
|
|
93
94
|
serviceFiles: 'api/services/*/*.service.mjs',
|
|
94
95
|
watchDirectories: ['api/services'],
|
|
95
96
|
adapter: ${JSON.stringify(options.adapter)},
|
|
96
97
|
prefix: '/api',
|
|
98
|
+
lifecycle: 'managed',
|
|
97
99
|
cors: false,
|
|
98
100
|
socketio: ${socket},
|
|
99
101
|
})`
|
|
@@ -154,7 +156,7 @@ function enableSpaPwa(files, options) {
|
|
|
154
156
|
export function createGenerator({outputRoot, templatesRoot, examplesRoot = templatesRoot, reportRoot = outputRoot}) {
|
|
155
157
|
const output = resolve(outputRoot), templates = resolve(templatesRoot), report = resolve(reportRoot)
|
|
156
158
|
function parse(args) {
|
|
157
|
-
const o={ui:'daisyui',render:'spa',adapter:'koa',transport:'rest',schema:'typebox',auth:'none',db:'main:memory',pwa:'off',devApi:undefined,dryRun:false,json:false}
|
|
159
|
+
const o={ui:'daisyui',render:'spa',adapter:'koa',transport:'rest',schema:'typebox',auth:'none',db:'main:memory',pwa:'off',compose:'off',devApi:undefined,dryRun:false,json:false}
|
|
158
160
|
const seen=new Set()
|
|
159
161
|
if (args.length && !args[0].startsWith('-')) {o.name=args[0];args=args.slice(1);if(args.includes('--name'))throw new Error('Use either positional name or --name, not both')}
|
|
160
162
|
for(let i=0;i<args.length;i++) {
|
|
@@ -170,6 +172,9 @@ export function createGenerator({outputRoot, templatesRoot, examplesRoot = templ
|
|
|
170
172
|
if (o.devApi === 'vite' && o.render !== 'spa') throw new Error('--dev-api vite is supported for SPA only; SSR/SSG require separate API')
|
|
171
173
|
if(!['koa','express'].includes(o.adapter))throw new Error('Only koa|express adapters are supported')
|
|
172
174
|
if(!['rest','both'].includes(o.transport))throw new Error('Only rest|both are supported')
|
|
175
|
+
if (!['off','dev','prod','both'].includes(o.compose)) throw new Error('Invalid --compose: expected off|dev|prod|both')
|
|
176
|
+
if (o.compose !== 'off' && o.render !== 'spa') throw new Error('Docker Compose currently supports SPA only; SSR/SSG must be certified separately')
|
|
177
|
+
if (o.compose !== 'off' && o.devApi !== 'vite' && o.compose !== 'prod') throw new Error('Compose dev requires --dev-api vite')
|
|
173
178
|
if (!['on','off'].includes(o.pwa)) throw new Error('Invalid --pwa: expected on|off')
|
|
174
179
|
if (o.pwa==='on' && o.render!=='spa') throw new Error('PWA is supported for SPA only; SSR/SSG require separate certification')
|
|
175
180
|
if (!['typebox','json','zod'].includes(o.schema)) throw new Error('Invalid --schema: expected typebox|json|zod')
|
|
@@ -201,7 +206,7 @@ export function createGenerator({outputRoot, templatesRoot, examplesRoot = templ
|
|
|
201
206
|
if(files.has('gitignore.template')){files.set('.gitignore',files.get('gitignore.template'));files.delete('gitignore.template')}
|
|
202
207
|
if(files.has('.gitignore')) {
|
|
203
208
|
const ignores=files.get('.gitignore')
|
|
204
|
-
const additions=['.agents/','AGENTS.md','SKILLS.md','*.prompt.md','.ai/','.codex/','.cursor/','.claude/','.gemini/','.windsurf/','.aider*','.mcp.json','.vscode/mcp.json','.github/copilot-instructions.md','docs-private/','.npmrc','.env*','!.env.example']
|
|
209
|
+
const additions=['.agents/','AGENTS.md','SKILLS.md','*.prompt.md','.ai/','.codex/','.cursor/','.claude/','.gemini/','.windsurf/','.aider*','.mcp.json','.vscode/mcp.json','.github/copilot-instructions.md','docs-private/','.npmrc','.env*','!.env.example','api/.create-feathers-service.lock']
|
|
205
210
|
files.set('.gitignore',ignores.trimEnd()+'\n\n# Local editor tooling and private maintenance files\n'+additions.filter(x=>!ignores.split(/\r?\n/).includes(x)).join('\n')+'\n')
|
|
206
211
|
}
|
|
207
212
|
if(!files.has('api/server.mjs')||!files.has('.env.example')||!files.has('index.html'))throw new Error('Incomplete source template')
|
|
@@ -244,17 +249,35 @@ export function createGenerator({outputRoot, templatesRoot, examplesRoot = templ
|
|
|
244
249
|
files.set('api/config/profile.json',JSON.stringify({schema:o.schema,auth:o.auth,databases:o.databases,primary:o.databases[0].alias,operational:o.auth==='none' && o.databases.every(db=>db.adapter==='memory')},null,2)+'\n')
|
|
245
250
|
const depMap={typebox:['@feathersjs/typebox','ajv'],json:['ajv'],zod:['zod']}
|
|
246
251
|
// Dependencies for the service subcommand; project options alone create no arbitrary services.
|
|
247
|
-
for(const dep of depMap[o.schema])manifest.dependencies[dep] ||= dep==='ajv'?'^8.17.1':dep==='zod'?'^4.1.0':'
|
|
248
|
-
if(o.auth==='jwt')manifest.dependencies['@feathersjs/authentication']='
|
|
249
|
-
if(o.auth==='oauth2')manifest.dependencies['@feathersjs/authentication-oauth']='
|
|
252
|
+
for(const dep of depMap[o.schema])manifest.dependencies[dep] ||= dep==='ajv'?'^8.17.1':dep==='zod'?'^4.1.0':'5.0.51'
|
|
253
|
+
if(o.auth==='jwt')manifest.dependencies['@feathersjs/authentication']='5.0.50'
|
|
254
|
+
if(o.auth==='oauth2')manifest.dependencies['@feathersjs/authentication-oauth']='5.0.51'
|
|
250
255
|
for(const db of o.databases) {
|
|
251
256
|
const drivers={mongodb:['@feathersjs/mongodb','mongodb'],postgresql:['@feathersjs/knex','knex','pg'],mariadb:['@feathersjs/knex','knex','mysql2'],mysql:['@feathersjs/knex','knex','mysql2'],sqlite:['@feathersjs/knex','knex','better-sqlite3']}
|
|
252
|
-
for(const dep of drivers[db.adapter]||[])manifest.dependencies[dep]||='*'
|
|
257
|
+
for(const dep of drivers[db.adapter]||[])manifest.dependencies[dep] ||= dep === 'mongodb' ? '6.19.0' : dep === '@feathersjs/mongodb' ? '5.0.51' : dep === '@feathersjs/knex' ? '5.0.50' : '*'
|
|
253
258
|
}
|
|
254
259
|
files.set('package.json',JSON.stringify(manifest,null,2)+'\n')
|
|
260
|
+
if (o.auth === 'jwt' && o.databases.some(db => ['postgresql','mariadb','mongodb'].includes(db.adapter))) {
|
|
261
|
+
for (const name of ['register.mjs','policy.mjs']) files.set('api/security/' + name, await readFile(new URL('./secure-runtime/' + name, import.meta.url), 'utf8'))
|
|
262
|
+
Object.assign(manifest.dependencies, { '@feathersjs/authentication-local': '5.0.50', '@casl/ability': '6.8.1', 'feathers-casl': '3.1.0' })
|
|
263
|
+
manifest.dependencies.zod ||= '^4.1.0'
|
|
264
|
+
files.set('package.json',JSON.stringify(manifest,null,2)+'\n')
|
|
265
|
+
}
|
|
266
|
+
const persistentDatabases = o.databases.filter(db => ['postgresql','mariadb','mongodb'].includes(db.adapter))
|
|
267
|
+
if (o.auth === 'jwt' && persistentDatabases.length) {
|
|
268
|
+
const declarations = persistentDatabases.map(db => ({ alias: db.alias, adapter: db.adapter, envPrefix: 'P5_DB_' + db.alias.replaceAll('-', '_').toUpperCase() }))
|
|
269
|
+
if (new Set(declarations.map(db => db.envPrefix)).size !== declarations.length) throw new Error('Database environment prefix collision')
|
|
270
|
+
files.set('api/config/database-connections.generated.mjs', 'export const databaseDeclarations = Object.freeze(' + JSON.stringify(declarations, null, 2) + ')\n')
|
|
271
|
+
for (const [source, target] of [['providers.mjs','api/runtime/database-providers.generated.mjs'], ['storage.mjs','api/runtime/storage.mjs'], ['prepare-storage.mjs','scripts/prepare-storage.mjs'], ['connect.mjs','api/security/connect.mjs']]) {
|
|
272
|
+
files.set(target, await readFile(new URL('./secure-runtime/' + source, import.meta.url), 'utf8'))
|
|
273
|
+
}
|
|
274
|
+
const env = declarations.flatMap(db => [db.envPrefix + '_HOST=127.0.0.1', db.envPrefix + '_PORT=' + (db.adapter === 'postgresql' ? '5432' : db.adapter === 'mariadb' ? '3306' : '27017'), db.envPrefix + '_USER=', db.envPrefix + '_PASSWORD=', db.envPrefix + '_NAME='])
|
|
275
|
+
files.set('database.env.example', env.join('\n') + '\n')
|
|
276
|
+
}
|
|
277
|
+
if (o.compose !== 'off') enableCompose(files, o)
|
|
255
278
|
const list=[...files.keys()].sort()
|
|
256
279
|
const result={name:o.name,ui:o.ui,render:o.render,adapter:o.adapter,transport:o.transport,
|
|
257
|
-
destination:relative(report,o.destination).split(sep).join('/'),schema:o.schema,auth:o.auth,pwa:o.pwa,devApi:o.devApi,databases:o.databases,files:list}
|
|
280
|
+
destination:relative(report,o.destination).split(sep).join('/'),schema:o.schema,auth:o.auth,pwa:o.pwa,compose:o.compose,devApi:o.devApi,databases:o.databases,files:list}
|
|
258
281
|
if(o.dryRun)return result
|
|
259
282
|
// Do not follow symlinks and refuse any existing destination, including empty ones.
|
|
260
283
|
await mkdir(output,{recursive:true})
|
|
@@ -276,9 +299,9 @@ function quasarSSRManifest(o){
|
|
|
276
299
|
scripts:{'dev:ssr':'quasar dev -m ssr','build:ssr':'quasar build -m ssr',build:'quasar build -m ssr',
|
|
277
300
|
typecheck:'vue-tsc --noEmit', 'dev:api':'node --env-file=.env.example api/server.mjs',
|
|
278
301
|
'serve:production':'node dist/ssr/index.js',postinstall:'quasar prepare', 'dev:edge':'node scripts/edge-proxy.mjs'},
|
|
279
|
-
dependencies:{'@vevedh/vite-plugin-feathersjs':'0.2.
|
|
280
|
-
'@feathersjs/express':'
|
|
281
|
-
'@feathersjs/rest-client':'
|
|
302
|
+
dependencies:{'@vevedh/vite-plugin-feathersjs':'0.2.7', '@feathersjs/feathers':'5.0.50', '@feathersjs/koa':'5.0.51',
|
|
303
|
+
'@feathersjs/express':'5.0.51', '@feathersjs/errors':'5.0.50', '@feathersjs/socketio':'5.0.51',
|
|
304
|
+
'@feathersjs/rest-client':'5.0.50', '@feathersjs/socketio-client':'5.0.50',
|
|
282
305
|
'@quasar/extras':'^1.18.0',pinia:'^3.0.4',quasar:'^2.35.2','socket.io-client':'^4.8.4',
|
|
283
306
|
vue:'^3.5.44','vue-router':'^4.6.4',express:'^4.22.3','serialize-javascript':'^7.1.4'},
|
|
284
307
|
devDependencies:{'@quasar/app-vite':'2.6.2','@types/node':'^22.0.0',typescript:'^5.9.3','vue-tsc':'^3.3.14',
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { openNamedDatabaseProviders } from '../runtime/database-providers.generated.mjs'
|
|
2
|
+
import { storageNames } from '../runtime/storage.mjs'
|
|
3
|
+
import { registerSecuredPersistence } from './register.mjs'
|
|
4
|
+
|
|
5
|
+
/** Attach real persistence/auth to an unstarted Feathers app; caller owns listener and shutdown.
|
|
6
|
+
* No schema creation, account seeding or socket broadcast occurs during startup.
|
|
7
|
+
*/
|
|
8
|
+
export async function connectSecuredPersistence(app, config) {
|
|
9
|
+
if (!config || Object.keys(config).sort().join(',') !== 'alias,audience,issuer,secret') throw Error('INVALID_SECURITY_CONFIG')
|
|
10
|
+
let providers
|
|
11
|
+
try {
|
|
12
|
+
providers = await openNamedDatabaseProviders()
|
|
13
|
+
const provider = providers.get(config.alias)
|
|
14
|
+
// Readiness checks the actual schema/collections before registration.
|
|
15
|
+
if (provider.adapter === 'mongodb') {
|
|
16
|
+
for (const name of Object.values(storageNames)) {
|
|
17
|
+
if (!await provider.db.listCollections({ name }).hasNext()) throw Error('STORAGE_NOT_PREPARED')
|
|
18
|
+
}
|
|
19
|
+
} else {
|
|
20
|
+
for (const name of Object.values(storageNames)) if (!await provider.db.schema.hasTable(name)) throw Error('STORAGE_NOT_PREPARED')
|
|
21
|
+
}
|
|
22
|
+
registerSecuredPersistence(app, providers, { ...config, ...storageNames })
|
|
23
|
+
return Object.freeze({ close: () => providers.close() })
|
|
24
|
+
} catch (error) {
|
|
25
|
+
try { await providers?.close() } catch {}
|
|
26
|
+
throw error
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** P5-R5-R4-R2: only server-validated identities determine Feathers/CASL permissions. */
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import { AbilityBuilder, createMongoAbility } from '@casl/ability'
|
|
4
|
+
import { BadRequest, NotAuthenticated } from '@feathersjs/errors'
|
|
5
|
+
|
|
6
|
+
import { z } from 'zod'
|
|
7
|
+
|
|
8
|
+
const id = z.string().uuid()
|
|
9
|
+
const title = z.string().trim().min(1).max(80).regex(/^[A-Za-z0-9 _.-]+$/)
|
|
10
|
+
const create = z.object({ title }).strict()
|
|
11
|
+
const patch = z.object({ title }).strict()
|
|
12
|
+
const list = z.object({
|
|
13
|
+
$limit: z.coerce.number().int().min(0).max(50).optional(),
|
|
14
|
+
$skip: z.coerce.number().int().min(0).max(100).optional(),
|
|
15
|
+
}).strict()
|
|
16
|
+
const empty = z.object({}).strict()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
export function validateIdentity(input ) {
|
|
27
|
+
if (typeof input !== 'object' || input === null || Array.isArray(input)) {
|
|
28
|
+
throw new NotAuthenticated('Identity unavailable')
|
|
29
|
+
}
|
|
30
|
+
const value = input
|
|
31
|
+
const parsed = z.object({
|
|
32
|
+
id,
|
|
33
|
+
role: z.enum(['viewer','editor','admin']),
|
|
34
|
+
status: z.literal('active'),
|
|
35
|
+
sessionVersion: z.number().int().positive().safe(),
|
|
36
|
+
}).passthrough().safeParse(value)
|
|
37
|
+
if (!parsed.success) throw new NotAuthenticated('Identity unavailable')
|
|
38
|
+
return parsed.data
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function serverAbility(identity ) {
|
|
42
|
+
const { can, rules } = new AbilityBuilder(createMongoAbility)
|
|
43
|
+
if (identity.role === 'admin') {
|
|
44
|
+
can('manage', 'all')
|
|
45
|
+
} else {
|
|
46
|
+
can('find', 'documents', ['id','title','ownerId'], { ownerId: identity.id })
|
|
47
|
+
can('get', 'documents', ['id','title','ownerId'], { ownerId: identity.id })
|
|
48
|
+
if (identity.role === 'editor') {
|
|
49
|
+
can('create', 'documents')
|
|
50
|
+
can('patch', 'documents', ['title'], { ownerId: identity.id })
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return createMongoAbility(rules)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
/** Must run after real Feathers authenticate('jwt') and before CASL authorize(). */
|
|
62
|
+
export function documentPolicy(revalidate ) {
|
|
63
|
+
return async (context ) => {
|
|
64
|
+
if (['get', 'patch', 'remove'].includes(context.method) && !id.safeParse(context.id).success) throw new BadRequest('Invalid id')
|
|
65
|
+
const params = context.params
|
|
66
|
+
if (!params.provider) throw new NotAuthenticated('Authenticated transport required')
|
|
67
|
+
const token = params.authentication?.accessToken
|
|
68
|
+
if (typeof token !== 'string' || token.length < 24 || token.length > 4096) {
|
|
69
|
+
throw new NotAuthenticated('Authentication required')
|
|
70
|
+
}
|
|
71
|
+
// Never use params.user, client JWT roles, client-supplied ability, or stale socket permissions.
|
|
72
|
+
const identity = validateIdentity(await revalidate(token))
|
|
73
|
+
params.ability = serverAbility(identity)
|
|
74
|
+
const query = (context.method === 'find' ? list : empty).safeParse(params.query ?? {})
|
|
75
|
+
if (!query.success) throw new BadRequest('Invalid query')
|
|
76
|
+
context.params.query = query.data
|
|
77
|
+
if (context.method === 'create' || context.method === 'patch') {
|
|
78
|
+
const parsed = (context.method === 'create' ? create : patch).safeParse(context.data )
|
|
79
|
+
if (!parsed.success) throw new BadRequest('Invalid data')
|
|
80
|
+
context.data = context.method === 'create'
|
|
81
|
+
? { ...parsed.data, id: randomUUID(), ownerId: identity.id, internal: null }
|
|
82
|
+
: parsed.data
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { openNamedDatabaseProviders } from '../api/runtime/database-providers.generated.mjs'
|
|
2
|
+
import { prepareIdentityStorage } from '../api/runtime/storage.mjs'
|
|
3
|
+
const [mode, flag, alias, ...extra] = process.argv.slice(2)
|
|
4
|
+
if (mode !== '--apply' || flag !== '--alias' || !/^[a-z][a-z0-9-]{0,31}$/.test(alias ?? '') || extra.length) {
|
|
5
|
+
console.error('STORAGE=REFUSED | EXPECTED=--apply --alias NAME'); process.exitCode = 2
|
|
6
|
+
} else {
|
|
7
|
+
let providers
|
|
8
|
+
try {
|
|
9
|
+
providers = await openNamedDatabaseProviders()
|
|
10
|
+
await prepareIdentityStorage(providers.get(alias))
|
|
11
|
+
console.log('STORAGE=PREPARED | ACCOUNTS_CREATED=0 | PUBLIC_RUNTIME=BLOCKED')
|
|
12
|
+
} catch { console.error('STORAGE=FAILED | PUBLIC_RUNTIME=BLOCKED'); process.exitCode = 2 }
|
|
13
|
+
finally { try { await providers?.close() } catch { console.error('STORAGE=CLEANUP_FAILED'); process.exitCode = 2 } }
|
|
14
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/** P5-R5-R4-R4-R1 — named Feathers providers. Internal-only, no HTTP routes. */
|
|
2
|
+
import { databaseDeclarations } from '../config/database-connections.generated.mjs'
|
|
3
|
+
const known = new Set(['postgresql', 'mariadb', 'mongodb'])
|
|
4
|
+
const tableName = /^[a-z][a-z0-9_]{0,56}$/
|
|
5
|
+
const hostName = /^(?:localhost|(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(?:\.(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?))*)$/
|
|
6
|
+
const accountName = /^[a-zA-Z_][a-zA-Z0-9_]{0,62}$/
|
|
7
|
+
const text = (env,key,min,max)=> { const v=env[key]; if(typeof v!=='string'||v.length<min||v.length>max||v.trim()!==v)throw Error('INVALID_DB_ENV');return v }
|
|
8
|
+
function readSettings(env, entry){
|
|
9
|
+
const prefix=entry.envPrefix
|
|
10
|
+
const host=text(env,`${prefix}_HOST`,1,253)
|
|
11
|
+
const rawPort=text(env,`${prefix}_PORT`,1,5)
|
|
12
|
+
const user=text(env,`${prefix}_USER`,1,63)
|
|
13
|
+
const password=text(env,`${prefix}_PASSWORD`,1,256)
|
|
14
|
+
const database=text(env,`${prefix}_NAME`,1,63)
|
|
15
|
+
const port=Number(rawPort)
|
|
16
|
+
if(!hostName.test(host)||!Number.isSafeInteger(port)||port<1||port>65535||String(port)!==rawPort||!accountName.test(user)||!accountName.test(database)||/\p{C}/u.test(password))throw Error('INVALID_DB_ENV')
|
|
17
|
+
return {host,port,user,password,database}
|
|
18
|
+
}
|
|
19
|
+
function validateDeclarations(){
|
|
20
|
+
if(!Array.isArray(databaseDeclarations)||databaseDeclarations.length<1||databaseDeclarations.length>8)throw Error('INVALID_DECLARATIONS')
|
|
21
|
+
const seen=new Set()
|
|
22
|
+
for(const d of databaseDeclarations){
|
|
23
|
+
if(!d||typeof d.alias!=='string'||!tableName.test(d.alias.replaceAll('-','_'))||!known.has(d.adapter)||typeof d.envPrefix!=='string'||!/^P5_DB_[A-Z][A-Z0-9_]*$/.test(d.envPrefix)||seen.has(d.alias))throw Error('INVALID_DECLARATIONS')
|
|
24
|
+
seen.add(d.alias)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function validateNamedProviderEnvironment(env=process.env){
|
|
28
|
+
validateDeclarations()
|
|
29
|
+
for(const d of databaseDeclarations)readSettings(env,d)
|
|
30
|
+
return databaseDeclarations.length
|
|
31
|
+
}
|
|
32
|
+
export async function openNamedDatabaseProviders(env=process.env){
|
|
33
|
+
validateNamedProviderEnvironment(env)
|
|
34
|
+
const providers=new Map()
|
|
35
|
+
let closed=false
|
|
36
|
+
const close=async()=>{
|
|
37
|
+
if(closed)return
|
|
38
|
+
closed=true
|
|
39
|
+
const failures=[]
|
|
40
|
+
for(const entry of [...providers.values()].reverse())try{await entry.close()}catch{failures.push(1)}
|
|
41
|
+
providers.clear()
|
|
42
|
+
if(failures.length)throw Error('DB_CLEANUP_FAILED')
|
|
43
|
+
}
|
|
44
|
+
try{
|
|
45
|
+
for(const declaration of databaseDeclarations){
|
|
46
|
+
const cfg=readSettings(env,declaration)
|
|
47
|
+
if(declaration.adapter==='mongodb'){
|
|
48
|
+
const { MongoClient }=await import('mongodb')
|
|
49
|
+
const { MongoDBService }=await import('@feathersjs/mongodb')
|
|
50
|
+
const client=new MongoClient(`mongodb://${cfg.host}:${cfg.port}`,{auth:{username:cfg.user,password:cfg.password},authSource:'admin',serverSelectionTimeoutMS:8000,maxPoolSize:3})
|
|
51
|
+
try{await client.connect();await client.db(cfg.database).command({ping:1})}
|
|
52
|
+
catch{await client.close();throw Error('DB_CONNECT_FAILED')}
|
|
53
|
+
const db=client.db(cfg.database)
|
|
54
|
+
providers.set(declaration.alias,{alias:declaration.alias,adapter:'mongodb',db,close:()=>client.close(), service(name){
|
|
55
|
+
if(typeof name!=='string'||!tableName.test(name))throw Error('INVALID_TABLE_NAME')
|
|
56
|
+
return new MongoDBService({Model:db.collection(name),id:'id',disableObjectify:true,paginate:false,disabledOperators:['$rename','$set','$inc','$unset','$push','$pull','$pop']})
|
|
57
|
+
}})
|
|
58
|
+
}else{
|
|
59
|
+
const { default: knex }=await import('knex')
|
|
60
|
+
const { KnexService }=await import('@feathersjs/knex')
|
|
61
|
+
const db=knex({client:declaration.adapter==='postgresql'?'pg':'mysql2',connection:{host:cfg.host,port:cfg.port,user:cfg.user,password:cfg.password,database:cfg.database},pool:{min:0,max:3},acquireConnectionTimeout:8000})
|
|
62
|
+
try{await db.raw('SELECT 1')}catch{await db.destroy();throw Error('DB_CONNECT_FAILED')}
|
|
63
|
+
providers.set(declaration.alias,{alias:declaration.alias,adapter:declaration.adapter,db,close:()=>db.destroy(),service(name){
|
|
64
|
+
if(typeof name!=='string'||!tableName.test(name))throw Error('INVALID_TABLE_NAME')
|
|
65
|
+
return new KnexService({Model:db,name,id:'id',paginate:false})
|
|
66
|
+
}})
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return Object.freeze({aliases:Object.freeze([...providers.keys()]),get(alias){
|
|
70
|
+
if(closed||typeof alias!=='string'||!providers.has(alias))throw Error('DB_ALIAS_UNAVAILABLE')
|
|
71
|
+
return providers.get(alias)
|
|
72
|
+
},close})
|
|
73
|
+
}catch{try{await close()}catch{};throw Error('DB_PROVIDER_BOOT_FAILED')}
|
|
74
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { AuthenticationService, JWTStrategy, authenticate } from '@feathersjs/authentication'
|
|
2
|
+
import { LocalStrategy } from '@feathersjs/authentication-local'
|
|
3
|
+
import { NotAuthenticated } from '@feathersjs/errors'
|
|
4
|
+
import { feathersCasl, authorize } from 'feathers-casl'
|
|
5
|
+
import { documentPolicy, validateIdentity } from './policy.mjs'
|
|
6
|
+
|
|
7
|
+
class SessionAuthentication extends AuthenticationService {
|
|
8
|
+
async getPayload(result) {
|
|
9
|
+
const identity = validateIdentity(result.user)
|
|
10
|
+
return { sessionVersion: identity.sessionVersion }
|
|
11
|
+
}
|
|
12
|
+
async create(data, params) {
|
|
13
|
+
const result = await super.create(data, params)
|
|
14
|
+
if (!result.user) return result
|
|
15
|
+
const user = validateIdentity(result.user)
|
|
16
|
+
// Explicit allowlist: never serialize the stored password or adapter metadata.
|
|
17
|
+
return { ...result, user: { id: user.id, role: user.role, status: user.status, sessionVersion: user.sessionVersion } }
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
class SessionJWT extends JWTStrategy {
|
|
21
|
+
async authenticate(data, params) {
|
|
22
|
+
const result = await super.authenticate(data, params)
|
|
23
|
+
const identity = validateIdentity(result.user)
|
|
24
|
+
if (identity.sessionVersion !== result.authentication?.payload?.sessionVersion) throw new NotAuthenticated('Invalid session')
|
|
25
|
+
return result
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
class ActiveLocal extends LocalStrategy {
|
|
29
|
+
async authenticate(data, params) {
|
|
30
|
+
const result = await super.authenticate(data, params)
|
|
31
|
+
validateIdentity(result.user)
|
|
32
|
+
return result
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Server-owned configuration only. Tables and indexes must be provisioned separately.
|
|
37
|
+
* Register before opening any HTTP or Socket.IO listener. No account is seeded here.
|
|
38
|
+
* The caller owns provider teardown. Existing generated capability guard remains authoritative.
|
|
39
|
+
*/
|
|
40
|
+
export function registerSecuredPersistence(app, providers, config) {
|
|
41
|
+
if (!config || Object.keys(config).sort().join(',') !== 'alias,audience,documents,issuer,secret,users') throw Error('INVALID_SECURITY_CONFIG')
|
|
42
|
+
if (typeof config.secret !== 'string' || config.secret.length < 48 || config.secret.length > 512 || /\s/.test(config.secret)) throw Error('INVALID_JWT_SECRET')
|
|
43
|
+
for (const key of ['issuer','audience']) if (typeof config[key] !== 'string' || !/^[a-zA-Z0-9:_-]{3,100}$/.test(config[key])) throw Error('INVALID_JWT_SCOPE')
|
|
44
|
+
for (const key of ['users','documents']) if (typeof config[key] !== 'string' || !/^[a-z][a-z0-9_]{0,55}$/.test(config[key])) throw Error('INVALID_STORAGE_NAME')
|
|
45
|
+
if (config.users === config.documents || typeof config.alias !== 'string' || !/^[a-z][a-z0-9-]{0,31}$/.test(config.alias)) throw Error('INVALID_BINDING')
|
|
46
|
+
if (!app || typeof app.use !== 'function' || !providers || typeof providers.get !== 'function') throw Error('INVALID_RUNTIME')
|
|
47
|
+
for (const path of ['users','authentication','documents']) if (app.services?.[path]) throw Error('SERVICE_COLLISION')
|
|
48
|
+
const provider = providers.get(config.alias)
|
|
49
|
+
if (!provider || !['mongodb','postgresql','mariadb'].includes(provider.adapter)) throw Error('UNSUPPORTED_PROVIDER')
|
|
50
|
+
const adapter = provider.adapter === 'mongodb' ? '@feathersjs/mongodb' : '@feathersjs/knex'
|
|
51
|
+
const users = provider.service(config.users)
|
|
52
|
+
const documents = provider.service(config.documents)
|
|
53
|
+
if (provider.adapter === 'mongodb') {
|
|
54
|
+
documents.options.filters = { ...documents.options.filters, $nor: true }
|
|
55
|
+
documents.options.operators = [...new Set([...(documents.options.operators ?? []), '$nor', '$and'])]
|
|
56
|
+
}
|
|
57
|
+
app.configure(feathersCasl({ defaultAdapter: adapter }))
|
|
58
|
+
app.set('authentication', {
|
|
59
|
+
secret: config.secret, service: 'users', entity: 'user', authStrategies: ['jwt','local'], parseStrategies: ['jwt'],
|
|
60
|
+
jwtOptions: { algorithm: 'HS256', expiresIn: '15m', issuer: config.issuer, audience: config.audience },
|
|
61
|
+
local: { usernameField: 'email', passwordField: 'password', hashSize: 10 },
|
|
62
|
+
})
|
|
63
|
+
app.use('users', users, { methods: [], events: [] })
|
|
64
|
+
const auth = new SessionAuthentication(app)
|
|
65
|
+
auth.register('jwt', new SessionJWT())
|
|
66
|
+
auth.register('local', new ActiveLocal())
|
|
67
|
+
app.use('authentication', auth, { methods: ['create','remove'], events: [] })
|
|
68
|
+
app.use('documents', documents, { methods: ['find','get','create','patch'], events: [] })
|
|
69
|
+
const access = authorize({ adapter, modelName: 'documents' })
|
|
70
|
+
app.service('documents').hooks({
|
|
71
|
+
around: { all: [authenticate('jwt')] },
|
|
72
|
+
before: { all: [documentPolicy(async accessToken => {
|
|
73
|
+
const result = await auth.authenticate({ strategy: 'jwt', accessToken }, {}, 'jwt')
|
|
74
|
+
return result.user
|
|
75
|
+
}), access] },
|
|
76
|
+
after: { all: [access] },
|
|
77
|
+
})
|
|
78
|
+
// No broadcast until recipient-level filtering has its own qualification.
|
|
79
|
+
return Object.freeze({ paths: Object.freeze(['authentication','documents']) })
|
|
80
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Explicit schema provisioning. Never drops tables, collections, records or indexes. */
|
|
2
|
+
export const storageNames = Object.freeze({ users: 'app_users', documents: 'app_documents' })
|
|
3
|
+
export async function prepareIdentityStorage(provider) {
|
|
4
|
+
if (!provider || !['postgresql','mariadb','mongodb'].includes(provider.adapter)) throw Error('UNSUPPORTED_PROVIDER')
|
|
5
|
+
const { users, documents } = storageNames
|
|
6
|
+
if (provider.adapter === 'mongodb') {
|
|
7
|
+
for (const name of [users, documents]) {
|
|
8
|
+
try { await provider.db.createCollection(name) } catch (error) { if (error?.code !== 48) throw error }
|
|
9
|
+
}
|
|
10
|
+
await provider.db.collection(users).createIndex({ id: 1 }, { unique: true })
|
|
11
|
+
await provider.db.collection(users).createIndex({ email: 1 }, { unique: true })
|
|
12
|
+
await provider.db.collection(documents).createIndex({ id: 1 }, { unique: true })
|
|
13
|
+
await provider.db.collection(documents).createIndex({ ownerId: 1 })
|
|
14
|
+
} else {
|
|
15
|
+
const db = provider.db
|
|
16
|
+
if (!await db.schema.hasTable(users)) await db.schema.createTable(users, t => {
|
|
17
|
+
t.string('id', 36).primary(); t.string('email', 120).notNullable().unique()
|
|
18
|
+
t.string('password', 120).notNullable(); t.string('role', 16).notNullable()
|
|
19
|
+
t.string('status', 16).notNullable(); t.integer('sessionVersion').notNullable()
|
|
20
|
+
})
|
|
21
|
+
if (!await db.schema.hasTable(documents)) await db.schema.createTable(documents, t => {
|
|
22
|
+
t.string('id', 36).primary(); t.string('title', 80).notNullable()
|
|
23
|
+
t.string('ownerId', 36).notNullable().index(); t.text('internal').nullable()
|
|
24
|
+
})
|
|
25
|
+
// Existing tables must contain every required column; no implicit destructive migration.
|
|
26
|
+
for (const [table, columns] of [[users, ['id','email','password','role','status','sessionVersion']], [documents, ['id','title','ownerId','internal']]]) {
|
|
27
|
+
for (const column of columns) if (!await db.schema.hasColumn(table, column)) throw Error('SCHEMA_MIGRATION_REQUIRED')
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return storageNames
|
|
31
|
+
}
|
package/lib/services.mjs
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
import { schemaSourceTs, classSourceTs, registrarTs, tsServiceConfig } from './typescript.mjs'
|
|
1
2
|
import { existsSync } from 'node:fs'
|
|
2
|
-
import { lstat, mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { lstat, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
3
4
|
import { dirname, join, resolve, sep } from 'node:path'
|
|
4
5
|
|
|
5
6
|
const VALID_NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/
|
|
6
7
|
const VALID_SCHEMA = new Set(['typebox','json','zod'])
|
|
7
8
|
const VALID_AUTH = new Set(['none','jwt'])
|
|
9
|
+
const VALID_LANGUAGE = new Set(['js','ts'])
|
|
8
10
|
function safeName(s){return typeof s==='string' && s.length<=48 && VALID_NAME.test(s) && !/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(s)}
|
|
9
11
|
async function rejectLinks(path){
|
|
10
12
|
let current=path
|
|
@@ -13,8 +15,8 @@ async function rejectLinks(path){
|
|
|
13
15
|
export function parseServiceArguments(args){
|
|
14
16
|
if(args[0]!=='add')throw new Error('Use: service add <name> [--project <dir>] [--schema typebox|json|zod] [--db <alias>] [--auth none|jwt] [--dry-run]')
|
|
15
17
|
if(!safeName(args[1]))throw new Error('Invalid service name (lowercase kebab-case)')
|
|
16
|
-
const options={name:args[1],project:'.',schema:undefined,db:undefined,auth:undefined,dryRun:false}
|
|
17
|
-
const keys=new Map([['--project','project'],['--schema','schema'],['--db','db'],['--auth','auth']])
|
|
18
|
+
const options={name:args[1],project:'.',schema:undefined,db:undefined,auth:undefined,lang:'js',dryRun:false}
|
|
19
|
+
const keys=new Map([['--project','project'],['--schema','schema'],['--db','db'],['--auth','auth'],['--lang','lang']])
|
|
18
20
|
const seen=new Set()
|
|
19
21
|
for(let i=2;i<args.length;i++){
|
|
20
22
|
const flag=args[i]
|
|
@@ -26,6 +28,7 @@ export function parseServiceArguments(args){
|
|
|
26
28
|
if(!value||value.startsWith('--'))throw new Error('Missing service option: '+flag)
|
|
27
29
|
options[keys.get(flag)]=value
|
|
28
30
|
}
|
|
31
|
+
if(!VALID_LANGUAGE.has(options.lang))throw new Error('Invalid language; use js|ts')
|
|
29
32
|
if(!VALID_SCHEMA.has(options.schema??'typebox'))throw new Error('Invalid schema')
|
|
30
33
|
if(!VALID_AUTH.has(options.auth??'none'))throw new Error('Invalid service auth: none|jwt')
|
|
31
34
|
if(options.db!==undefined&&!safeName(options.db))throw new Error('Invalid database alias')
|
|
@@ -34,30 +37,82 @@ export function parseServiceArguments(args){
|
|
|
34
37
|
}
|
|
35
38
|
function schemaSource(schema){
|
|
36
39
|
const opening="import { BadRequest } from '@feathersjs/errors'\n"
|
|
40
|
+
const query=`
|
|
41
|
+
// Only a deliberately small, documented query dialect is accepted.
|
|
42
|
+
// Feathers adapter-specific $ operators must never pass through unchecked.
|
|
43
|
+
export function validateQuery(value){
|
|
44
|
+
if(value === undefined || value === null) return {}
|
|
45
|
+
if(typeof value !== 'object' || Array.isArray(value)) throw new BadRequest('Invalid query')
|
|
46
|
+
const result={}
|
|
47
|
+
for(const [key,raw] of Object.entries(value)){
|
|
48
|
+
if(key==='title'){
|
|
49
|
+
if(typeof raw!=='string'||raw.length>120||raw.trim().length<1)throw new BadRequest('Invalid query title')
|
|
50
|
+
result.title=raw.trim()
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
if(key==='$limit'||key==='$skip'){
|
|
54
|
+
if(typeof raw!=='number'&&typeof raw!=='string')throw new BadRequest('Invalid query pagination')
|
|
55
|
+
if(typeof raw==='string'&&!/^(0|[1-9][0-9]*)$/.test(raw))throw new BadRequest('Invalid query pagination')
|
|
56
|
+
const n=Number(raw)
|
|
57
|
+
if(!Number.isSafeInteger(n)||n<0||n>1000)throw new BadRequest('Invalid query pagination')
|
|
58
|
+
result[key]=n
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
throw new BadRequest('Unsupported query field')
|
|
62
|
+
}
|
|
63
|
+
return result
|
|
64
|
+
}
|
|
65
|
+
`
|
|
37
66
|
if(schema==='zod')return opening+`import { z } from 'zod'
|
|
38
67
|
export const dataSchema=z.object({title:z.string().trim().min(1).max(120)}).strict()
|
|
68
|
+
export const patchSchema=dataSchema.partial()
|
|
39
69
|
export function validateData(value){const parsed=dataSchema.safeParse(value);if(!parsed.success)throw new BadRequest('Invalid service data');return parsed.data}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
70
|
+
export function validatePatch(value){const parsed=patchSchema.safeParse(value);if(!parsed.success||!Object.keys(parsed.data).length)throw new BadRequest('Invalid service patch');return parsed.data}
|
|
71
|
+
`+query
|
|
72
|
+
const shape=schema==='typebox'?`import { Type } from '@feathersjs/typebox'
|
|
73
|
+
const titleSchema=Type.String({minLength:1,maxLength:120})
|
|
74
|
+
export const dataSchema=Type.Object({title:titleSchema},{additionalProperties:false})
|
|
75
|
+
export const patchSchema=Type.Object({title:Type.Optional(titleSchema)},{additionalProperties:false})
|
|
76
|
+
`:`const titleSchema={type:'string',minLength:1,maxLength:120}
|
|
77
|
+
export const dataSchema={type:'object',additionalProperties:false,required:['title'],properties:{title:titleSchema}}
|
|
78
|
+
export const patchSchema={type:'object',additionalProperties:false,properties:{title:titleSchema}}
|
|
44
79
|
`
|
|
45
80
|
return opening+`import Ajv from 'ajv'
|
|
46
|
-
`+
|
|
47
|
-
|
|
48
|
-
|
|
81
|
+
`+shape+`const ajv=new Ajv({allErrors:true,coerceTypes:false,removeAdditional:false,useDefaults:false})
|
|
82
|
+
const validData=ajv.compile(dataSchema)
|
|
83
|
+
const validPatch=ajv.compile(patchSchema)
|
|
84
|
+
function normalized(value, validator, operation){
|
|
85
|
+
// Apply the same trim-before-validation contract as the Zod preset.
|
|
86
|
+
if(!value||typeof value!=='object'||Array.isArray(value)||typeof value.title!=='string')throw new BadRequest('Invalid service '+operation)
|
|
87
|
+
const canonical={...value,title:value.title.trim()}
|
|
88
|
+
if(!validator(canonical)||!canonical.title)throw new BadRequest('Invalid service '+operation)
|
|
89
|
+
return {title:canonical.title}
|
|
90
|
+
}
|
|
91
|
+
export function validateData(value){return normalized(value,validData,'data')}
|
|
92
|
+
export function validatePatch(value){return normalized(value,validPatch,'patch')}
|
|
93
|
+
`+query
|
|
49
94
|
}
|
|
50
95
|
function classSource(){return `import { NotFound, BadRequest } from '@feathersjs/errors'
|
|
51
|
-
import { validateData } from './schema.mjs'
|
|
96
|
+
import { validateData, validatePatch, validateQuery } from './schema.mjs'
|
|
52
97
|
export class GeneratedService {
|
|
53
98
|
#items=new Map()
|
|
54
99
|
#counter=1
|
|
55
100
|
constructor(){for(const method of ['find','get','create','patch','remove'])this[method]=this[method].bind(this)}
|
|
56
|
-
#id(value){
|
|
57
|
-
|
|
101
|
+
#id(value){
|
|
102
|
+
if(typeof value!=='number'&&typeof value!=='string')throw new BadRequest('Invalid ID')
|
|
103
|
+
if(typeof value==='string'&&!/^[1-9][0-9]*$/.test(value))throw new BadRequest('Invalid ID')
|
|
104
|
+
const n=Number(value)
|
|
105
|
+
if(!Number.isSafeInteger(n)||n<1)throw new BadRequest('Invalid ID')
|
|
106
|
+
return n
|
|
107
|
+
}
|
|
108
|
+
async find(params={}){
|
|
109
|
+
const query=validateQuery(params?.query)
|
|
110
|
+
const values=[...this.#items.values()].filter(row=>query.title===undefined||row.title===query.title)
|
|
111
|
+
return values.slice(query.$skip??0,(query.$skip??0)+(query.$limit??values.length)).map(row=>({...row}))
|
|
112
|
+
}
|
|
58
113
|
async get(id){const item=this.#items.get(this.#id(id));if(!item)throw new NotFound('Not found');return {...item}}
|
|
59
|
-
async create(data){const row={id:this.#counter++,...
|
|
60
|
-
async patch(id,data){const key=this.#id(id);
|
|
114
|
+
async create(data){const input=validateData(data);const row={id:this.#counter++,...input};this.#items.set(row.id,row);return {...row}}
|
|
115
|
+
async patch(id,data){const key=this.#id(id);const row=this.#items.get(key);if(!row)throw new NotFound('Not found');const next={...row,...validatePatch(data)};this.#items.set(key,next);return {...next}}
|
|
61
116
|
async remove(id){const key=this.#id(id);const row=this.#items.get(key);if(!row)throw new NotFound('Not found');this.#items.delete(key);return {...row}}
|
|
62
117
|
}
|
|
63
118
|
`}
|
|
@@ -69,40 +124,102 @@ ${auth==='jwt'?"import { authenticate } from '@feathersjs/authentication'\n":''}
|
|
|
69
124
|
}
|
|
70
125
|
`
|
|
71
126
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if(opts
|
|
127
|
+
// Non-public IO injection enables deterministic Windows-safe fault tests. CLI calls use default fs.
|
|
128
|
+
export async function generateService(opts, cwd=process.cwd(), io={writeFile}) {
|
|
129
|
+
if(!opts || !safeName(opts.name))throw new Error('Invalid service name')
|
|
130
|
+
const projectOption=opts.project??'.'
|
|
131
|
+
if(typeof projectOption!=='string'||!/^\.?\/?[a-zA-Z0-9._/-]*$/.test(projectOption)||projectOption.includes('..')||projectOption.includes('\\')||projectOption.startsWith('/'))throw new Error('Unsafe project path')
|
|
132
|
+
if(opts.schema!==undefined&&!VALID_SCHEMA.has(opts.schema))throw new Error('Invalid schema')
|
|
133
|
+
if(opts.lang!==undefined&&!VALID_LANGUAGE.has(opts.lang))throw new Error('Invalid language')
|
|
134
|
+
if(opts.auth!==undefined&&!VALID_AUTH.has(opts.auth))throw new Error('Invalid service auth')
|
|
135
|
+
if(opts.db!==undefined&&!safeName(opts.db))throw new Error('Invalid database alias')
|
|
136
|
+
const project=resolve(cwd,projectOption)
|
|
137
|
+
if(projectOption!=='.' && !project.startsWith(resolve(cwd)+sep))throw new Error('Project escapes current directory')
|
|
75
138
|
await rejectLinks(project)
|
|
76
139
|
const manifestPath=join(project,'package.json'),serverPath=join(project,'api/server.mjs'),profilePath=join(project,'api/config/profile.json')
|
|
140
|
+
const lockPath=join(project,'api/.create-feathers-service.lock')
|
|
141
|
+
for(const file of [manifestPath,serverPath,profilePath,lockPath]) await rejectLinks(file)
|
|
77
142
|
if(!existsSync(manifestPath)||!existsSync(serverPath)||!existsSync(profilePath))throw new Error('Not a CLI generated project')
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if(!
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
await
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
143
|
+
// Only one service may update the registry and manifest at a time. A stale lock
|
|
144
|
+
// must be inspected by the operator rather than automatically stolen.
|
|
145
|
+
let ownsLock=false
|
|
146
|
+
if(!opts.dryRun){
|
|
147
|
+
try{await writeFile(lockPath,JSON.stringify({pid:process.pid})+'\n',{flag:'wx'});ownsLock=true}
|
|
148
|
+
catch(error){if(error?.code==='EEXIST')throw new Error('Concurrent service generation or stale lock; refusing changes');throw error}
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
const manifestSource=await readFile(manifestPath,'utf8')
|
|
152
|
+
const text=await readFile(serverPath,'utf8')
|
|
153
|
+
const manifest=JSON.parse(manifestSource)
|
|
154
|
+
const profile=JSON.parse(await readFile(profilePath,'utf8'))
|
|
155
|
+
if(!manifest||typeof manifest!=='object'||Array.isArray(manifest)||!manifest.dependencies||typeof manifest.dependencies!=='object'||Array.isArray(manifest.dependencies))throw new Error('Invalid project dependencies manifest')
|
|
156
|
+
const schema=opts.schema??profile.schema,auth=opts.auth??profile.auth,db=opts.db??profile.primary,lang=opts.lang??'js'
|
|
157
|
+
if(!VALID_SCHEMA.has(schema))throw new Error('Invalid schema')
|
|
158
|
+
if(!VALID_AUTH.has(auth))throw new Error('Service supports none|jwt; OAuth2/OIDC/Keycloak require audited identity integration')
|
|
159
|
+
if(auth!=='none' && profile.auth!==auth)throw new Error('Service auth must match configured project profile')
|
|
160
|
+
if(!Array.isArray(profile.databases))throw new Error('Invalid project database profile')
|
|
161
|
+
const connection=profile.databases.find(x=>x && x.alias===db)
|
|
162
|
+
if(!connection)throw new Error('Unknown database alias: '+db)
|
|
163
|
+
const destination=join(project,'api/services',opts.name)
|
|
164
|
+
await rejectLinks(destination)
|
|
165
|
+
if(existsSync(destination))throw new Error('Service already exists; refusing overwrite')
|
|
166
|
+
const marker='const modules = [\n'
|
|
167
|
+
if(text.split(marker).length!==2)throw new Error('Missing or ambiguous Feathers modules registry; no changes made')
|
|
168
|
+
const ext=lang==='ts'?'ts':'mjs'
|
|
169
|
+
const moduleEntry=` new URL('./services/${opts.name}/${opts.name}.service.${ext}', import.meta.url),\n`
|
|
170
|
+
if(text.includes(moduleEntry))throw new Error('Duplicate service registration')
|
|
171
|
+
const tsReady = lang !== 'ts' || (typeof manifest.scripts?.['dev:api'] === 'string' && manifest.scripts['dev:api'].includes('api/server.mjs') && manifest.scripts?.['dev:all'] !== 'vite --host 127.0.0.1')
|
|
172
|
+
const ready=connection.adapter==='memory'&&auth==='none'&&profile.auth==='none'&&tsReady
|
|
173
|
+
const output={name:opts.name,project,database:{alias:db,adapter:connection.adapter},auth,schema,lang,files:[`api/services/${opts.name}/schema.${ext}`,`api/services/${opts.name}/service.class.${ext}`,`api/services/${opts.name}/${opts.name}.service.${ext}`],ready}
|
|
174
|
+
if(opts.dryRun)return output
|
|
175
|
+
if(connection.adapter!=='memory')throw new Error('Database adapter not provisioned for service: '+connection.adapter+'. Refusing implicit in-memory fallback')
|
|
176
|
+
if(!tsReady)throw new Error('TypeScript services require a separate API runtime; Vite-embedded runtime not yet certified')
|
|
177
|
+
if(!ready)throw new Error('Authentication infrastructure not provisioned; refusing service generation')
|
|
178
|
+
const deps=schema==='zod'?{'zod':'^4.1.0'}:schema==='typebox'?{'@feathersjs/typebox':'5.0.51','ajv':'^8.17.1'}:{'ajv':'^8.17.1'}
|
|
179
|
+
const dependencies={...manifest.dependencies}
|
|
180
|
+
for(const [name,version] of Object.entries(deps))dependencies[name]??=version
|
|
181
|
+
let manifestResult
|
|
182
|
+
const tsconfigPath = join(project, 'api/tsconfig.json')
|
|
183
|
+
let writeTsconfig = false
|
|
184
|
+
if(lang==='ts'){
|
|
185
|
+
await rejectLinks(tsconfigPath)
|
|
186
|
+
if(existsSync(tsconfigPath)){
|
|
187
|
+
const existing = JSON.parse(await readFile(tsconfigPath,'utf8'))
|
|
188
|
+
if(existing?.compilerOptions?.strict!==true||existing?.compilerOptions?.noEmit!==true||existing?.compilerOptions?.allowImportingTsExtensions!==true)throw new Error('Unsafe existing TypeScript API configuration')
|
|
189
|
+
} else writeTsconfig=true
|
|
190
|
+
// Node --import tsx ensures .ts service registration is executable by the standalone Feathers API.
|
|
191
|
+
const scripts={...manifest.scripts,'dev:api':manifest.scripts['dev:api'].includes('--import tsx')?manifest.scripts['dev:api']:manifest.scripts['dev:api'].replace(/^node\s+/, 'node --import tsx '), 'typecheck:api':'tsc --noEmit -p api/tsconfig.json'}
|
|
192
|
+
// Preserve any project-level Vue/Quasar typecheck command while requiring strict backend checks.
|
|
193
|
+
if(!scripts.typecheck?.includes('typecheck:api')) scripts.typecheck=(scripts.typecheck??'tsc --noEmit')+' && npm run typecheck:api'
|
|
194
|
+
const devDependencies={...manifest.devDependencies,tsx:manifest.devDependencies?.tsx??'^4.20.0',typescript:manifest.devDependencies?.typescript??'^5.9.3','@types/node':manifest.devDependencies?.['@types/node']??'^22.0.0'}
|
|
195
|
+
manifestResult=JSON.stringify({...manifest,dependencies,devDependencies,scripts},null,2)+'\n'
|
|
196
|
+
} else manifestResult=JSON.stringify({...manifest,dependencies},null,2)+'\n'
|
|
197
|
+
const files=lang==='ts'
|
|
198
|
+
? Object.entries({'schema.ts':schemaSourceTs(schema),'service.class.ts':classSourceTs(),[`${opts.name}.service.ts`]:registrarTs(opts.name)})
|
|
199
|
+
: Object.entries({'schema.mjs':schemaSource(schema),'service.class.mjs':classSource(),[`${opts.name}.service.mjs`]:registrar(opts.name,auth)})
|
|
200
|
+
let directoryCreated=false, serverAttempted=false, manifestAttempted=false, tsconfigAttempted=false
|
|
201
|
+
try {
|
|
202
|
+
await mkdir(destination,{recursive:false});directoryCreated=true
|
|
203
|
+
for(const [name,contents] of files)await io.writeFile(join(destination,name),contents,{flag:'wx'})
|
|
204
|
+
if(writeTsconfig){tsconfigAttempted=true;await io.writeFile(tsconfigPath,tsServiceConfig,{flag:'wx'})}
|
|
205
|
+
serverAttempted=true
|
|
206
|
+
await io.writeFile(serverPath,text.replace(marker,marker+moduleEntry))
|
|
207
|
+
manifestAttempted=true
|
|
208
|
+
await io.writeFile(manifestPath,manifestResult)
|
|
209
|
+
} catch(error) {
|
|
210
|
+
const failures=[]
|
|
211
|
+
// Restore even when a write was interrupted in the middle of a file.
|
|
212
|
+
if(manifestAttempted)try{await writeFile(manifestPath,manifestSource)}catch(e){failures.push(e)}
|
|
213
|
+
if(tsconfigAttempted)try{await rm(tsconfigPath,{force:true})}catch(e){failures.push(e)}
|
|
214
|
+
if(serverAttempted)try{await writeFile(serverPath,text)}catch(e){failures.push(e)}
|
|
215
|
+
if(directoryCreated)try{await rm(destination,{recursive:true,force:true})}catch(e){failures.push(e)}
|
|
216
|
+
if(failures.length)throw new AggregateError([error,...failures],'Service generation failed; rollback incomplete; inspect project before retry')
|
|
217
|
+
throw error
|
|
218
|
+
}
|
|
219
|
+
return output
|
|
220
|
+
} finally {
|
|
221
|
+
if(ownsLock)await rm(lockPath,{force:true})
|
|
222
|
+
}
|
|
106
223
|
}
|
|
107
224
|
export async function runServiceCommand(args,cwd){
|
|
108
225
|
const result=await generateService(parseServiceArguments(args),cwd)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Opt-in strict TypeScript Feathers service output for separately hosted API.
|
|
2
|
+
// Keep the established JS defaults untouched; never generate a TypeScript facade.
|
|
3
|
+
export const tsServiceConfig = JSON.stringify({
|
|
4
|
+
compilerOptions: {
|
|
5
|
+
target: 'ES2022', module: 'NodeNext', moduleResolution: 'NodeNext',
|
|
6
|
+
lib: ['ES2022'], types: ['node'], strict: true, noEmit: true,
|
|
7
|
+
isolatedModules: true, exactOptionalPropertyTypes: true,
|
|
8
|
+
noUncheckedIndexedAccess: true, allowImportingTsExtensions: true,
|
|
9
|
+
skipLibCheck: true,
|
|
10
|
+
}, include: ['services/**/*.ts'],
|
|
11
|
+
}, null, 2) + '\n'
|
|
12
|
+
|
|
13
|
+
const querySource = `
|
|
14
|
+
export interface ValidQuery { title?: string; $limit?: number; $skip?: number }
|
|
15
|
+
export function validateQuery(value: unknown): ValidQuery {
|
|
16
|
+
if (value === undefined || value === null) return {}
|
|
17
|
+
if (typeof value !== 'object' || Array.isArray(value)) throw new BadRequest('Invalid query')
|
|
18
|
+
const result: ValidQuery = {}
|
|
19
|
+
for (const [key, raw] of Object.entries(value)) {
|
|
20
|
+
if (key === 'title') {
|
|
21
|
+
if (typeof raw !== 'string' || raw.length > 120 || raw.trim().length < 1) throw new BadRequest('Invalid query title')
|
|
22
|
+
result.title = raw.trim()
|
|
23
|
+
continue
|
|
24
|
+
}
|
|
25
|
+
if (key === '$limit' || key === '$skip') {
|
|
26
|
+
if (typeof raw !== 'number' && typeof raw !== 'string') throw new BadRequest('Invalid query pagination')
|
|
27
|
+
if (typeof raw === 'string' && !/^(0|[1-9][0-9]*)$/.test(raw)) throw new BadRequest('Invalid query pagination')
|
|
28
|
+
const n = Number(raw)
|
|
29
|
+
if (!Number.isSafeInteger(n) || n < 0 || n > 1000) throw new BadRequest('Invalid query pagination')
|
|
30
|
+
result[key] = n
|
|
31
|
+
continue
|
|
32
|
+
}
|
|
33
|
+
throw new BadRequest('Unsupported query field')
|
|
34
|
+
}
|
|
35
|
+
return result
|
|
36
|
+
}
|
|
37
|
+
`
|
|
38
|
+
|
|
39
|
+
export function schemaSourceTs(schema) {
|
|
40
|
+
const opening = "import { BadRequest } from '@feathersjs/errors'\n" +
|
|
41
|
+
'export interface ServiceData { title: string }\n' +
|
|
42
|
+
'export type ServicePatch = Partial<ServiceData>\n'
|
|
43
|
+
if (schema === 'zod') return opening + `import { z } from 'zod'
|
|
44
|
+
export const dataSchema = z.object({title: z.string().trim().min(1).max(120)}).strict()
|
|
45
|
+
export const patchSchema = dataSchema.partial()
|
|
46
|
+
export function validateData(value: unknown): ServiceData {
|
|
47
|
+
const parsed = dataSchema.safeParse(value)
|
|
48
|
+
if (!parsed.success) throw new BadRequest('Invalid service data')
|
|
49
|
+
return parsed.data
|
|
50
|
+
}
|
|
51
|
+
export function validatePatch(value: unknown): ServicePatch {
|
|
52
|
+
const parsed = patchSchema.safeParse(value)
|
|
53
|
+
// .partial() permits an explicit undefined; never allow it to erase an existing title.
|
|
54
|
+
if (!parsed.success || typeof parsed.data.title !== 'string') throw new BadRequest('Invalid service patch')
|
|
55
|
+
return { title: parsed.data.title }
|
|
56
|
+
}
|
|
57
|
+
` + querySource
|
|
58
|
+
const declarations = schema === 'typebox'
|
|
59
|
+
? `import { Type } from '@feathersjs/typebox'
|
|
60
|
+
const titleSchema = Type.String({minLength:1,maxLength:120})
|
|
61
|
+
export const dataSchema = Type.Object({title:titleSchema},{additionalProperties:false})
|
|
62
|
+
export const patchSchema = Type.Object({title:Type.Optional(titleSchema)},{additionalProperties:false})
|
|
63
|
+
`
|
|
64
|
+
: `const titleSchema = {type:'string',minLength:1,maxLength:120}
|
|
65
|
+
export const dataSchema = {type:'object',additionalProperties:false,required:['title'],properties:{title:titleSchema}}
|
|
66
|
+
export const patchSchema = {type:'object',additionalProperties:false,properties:{title:titleSchema}}
|
|
67
|
+
`
|
|
68
|
+
return opening + "import AjvModule, { type ValidateFunction } from 'ajv'\n" + declarations + `// Ajv v8 ships CommonJS: NodeNext resolves its default import to the module namespace.\nconst Ajv = AjvModule.default\nconst ajv = new Ajv({allErrors:true,coerceTypes:false,removeAdditional:false,useDefaults:false})
|
|
69
|
+
const validData = ajv.compile(dataSchema)
|
|
70
|
+
const validPatch = ajv.compile(patchSchema)
|
|
71
|
+
function normalized(value: unknown, validator: ValidateFunction, operation: string): ServiceData {
|
|
72
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value) ||
|
|
73
|
+
!('title' in value) || typeof value.title !== 'string') throw new BadRequest('Invalid service ' + operation)
|
|
74
|
+
const canonical = { ...value, title: value.title.trim() }
|
|
75
|
+
if (!validator(canonical) || !canonical.title) throw new BadRequest('Invalid service ' + operation)
|
|
76
|
+
return { title: canonical.title }
|
|
77
|
+
}
|
|
78
|
+
export function validateData(value: unknown): ServiceData { return normalized(value, validData, 'data') }
|
|
79
|
+
export function validatePatch(value: unknown): ServicePatch { return normalized(value, validPatch, 'patch') }
|
|
80
|
+
` + querySource
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function classSourceTs() {
|
|
84
|
+
return `import { NotFound, BadRequest } from '@feathersjs/errors'
|
|
85
|
+
import type { Id, Params } from '@feathersjs/feathers'
|
|
86
|
+
import { validateData, validatePatch, validateQuery, type ServiceData, type ServicePatch } from './schema.ts'
|
|
87
|
+
export interface ServiceRecord { id: number; title: string }
|
|
88
|
+
export class GeneratedService {
|
|
89
|
+
readonly #items = new Map<number, ServiceRecord>()
|
|
90
|
+
#counter = 1
|
|
91
|
+
constructor() {
|
|
92
|
+
this.find = this.find.bind(this)
|
|
93
|
+
this.get = this.get.bind(this)
|
|
94
|
+
this.create = this.create.bind(this)
|
|
95
|
+
this.patch = this.patch.bind(this)
|
|
96
|
+
this.remove = this.remove.bind(this)
|
|
97
|
+
}
|
|
98
|
+
#id(value: Id): number {
|
|
99
|
+
if (typeof value !== 'number' && typeof value !== 'string') throw new BadRequest('Invalid ID')
|
|
100
|
+
if (typeof value === 'string' && !/^[1-9][0-9]*$/.test(value)) throw new BadRequest('Invalid ID')
|
|
101
|
+
const n = Number(value)
|
|
102
|
+
if (!Number.isSafeInteger(n) || n < 1) throw new BadRequest('Invalid ID')
|
|
103
|
+
return n
|
|
104
|
+
}
|
|
105
|
+
async find(params: Params = {}): Promise<ServiceRecord[]> {
|
|
106
|
+
const query = validateQuery(params.query)
|
|
107
|
+
const values = [...this.#items.values()].filter(row => query.title === undefined || row.title === query.title)
|
|
108
|
+
return values.slice(query.$skip ?? 0, (query.$skip ?? 0) + (query.$limit ?? values.length)).map(row => ({...row}))
|
|
109
|
+
}
|
|
110
|
+
async get(id: Id): Promise<ServiceRecord> {
|
|
111
|
+
const item = this.#items.get(this.#id(id))
|
|
112
|
+
if (!item) throw new NotFound('Not found')
|
|
113
|
+
return {...item}
|
|
114
|
+
}
|
|
115
|
+
async create(data: ServiceData): Promise<ServiceRecord> {
|
|
116
|
+
const input = validateData(data)
|
|
117
|
+
const row = {id: this.#counter++, ...input}
|
|
118
|
+
this.#items.set(row.id, row)
|
|
119
|
+
return {...row}
|
|
120
|
+
}
|
|
121
|
+
async patch(id: Id, data: ServicePatch): Promise<ServiceRecord> {
|
|
122
|
+
const key = this.#id(id)
|
|
123
|
+
const row = this.#items.get(key)
|
|
124
|
+
if (!row) throw new NotFound('Not found')
|
|
125
|
+
const next = {...row, ...validatePatch(data)}
|
|
126
|
+
this.#items.set(key, next)
|
|
127
|
+
return {...next}
|
|
128
|
+
}
|
|
129
|
+
async remove(id: Id): Promise<ServiceRecord> {
|
|
130
|
+
const key = this.#id(id)
|
|
131
|
+
const row = this.#items.get(key)
|
|
132
|
+
if (!row) throw new NotFound('Not found')
|
|
133
|
+
this.#items.delete(key)
|
|
134
|
+
return {...row}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
`
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function registrarTs(name) {
|
|
141
|
+
return `import type { Application } from '@feathersjs/feathers'
|
|
142
|
+
import { GeneratedService } from './service.class.ts'
|
|
143
|
+
export default function register(app: Application): void {
|
|
144
|
+
app.use('${name}', new GeneratedService(), {methods: ['find','get','create','patch','remove']})
|
|
145
|
+
// No authentication: demo service must only listen on loopback.
|
|
146
|
+
}
|
|
147
|
+
`
|
|
148
|
+
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vevedh/create-feathers-app",
|
|
3
|
-
"version": "0.1.0-rc.
|
|
3
|
+
"version": "0.1.0-rc.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Scaffold FeathersJS v5 projects with Quasar 2 or daisyUI, Vite, Vue 3 and Pinia",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"bin": {
|
|
8
|
-
"create-feathers-app": "
|
|
8
|
+
"create-feathers-app": "bin/create-feathers-app.mjs"
|
|
9
9
|
},
|
|
10
10
|
"engines": {
|
|
11
11
|
"node": ">=22.12.0"
|
|
@@ -15,12 +15,12 @@
|
|
|
15
15
|
"serve:production": "node scripts/serve-static.mjs"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@vevedh/vite-plugin-feathersjs": "0.2.
|
|
19
|
-
"@feathersjs/feathers": "
|
|
20
|
-
"@feathersjs/errors": "
|
|
21
|
-
"@feathersjs/koa": "
|
|
22
|
-
"@feathersjs/express": "
|
|
23
|
-
"@feathersjs/socketio": "
|
|
18
|
+
"@vevedh/vite-plugin-feathersjs": "0.2.7",
|
|
19
|
+
"@feathersjs/feathers": "5.0.50",
|
|
20
|
+
"@feathersjs/errors": "5.0.50",
|
|
21
|
+
"@feathersjs/koa": "5.0.51",
|
|
22
|
+
"@feathersjs/express": "5.0.51",
|
|
23
|
+
"@feathersjs/socketio": "5.0.51",
|
|
24
24
|
"vue": "^3.5.44",
|
|
25
25
|
"pinia": "^3.0.4",
|
|
26
26
|
"socket.io-client": "^4.8.4",
|
|
@@ -15,12 +15,12 @@
|
|
|
15
15
|
"serve:production": "node scripts/serve-ssr.mjs"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@vevedh/vite-plugin-feathersjs": "0.2.
|
|
19
|
-
"@feathersjs/feathers": "
|
|
20
|
-
"@feathersjs/errors": "
|
|
21
|
-
"@feathersjs/koa": "
|
|
22
|
-
"@feathersjs/express": "
|
|
23
|
-
"@feathersjs/socketio": "
|
|
18
|
+
"@vevedh/vite-plugin-feathersjs": "0.2.7",
|
|
19
|
+
"@feathersjs/feathers": "5.0.50",
|
|
20
|
+
"@feathersjs/errors": "5.0.50",
|
|
21
|
+
"@feathersjs/koa": "5.0.51",
|
|
22
|
+
"@feathersjs/express": "5.0.51",
|
|
23
|
+
"@feathersjs/socketio": "5.0.51",
|
|
24
24
|
"vue": "^3.5.44",
|
|
25
25
|
"pinia": "^3.0.4",
|
|
26
26
|
"socket.io-client": "^4.8.4",
|