@vevedh/create-feathers-app 0.0.0-stage → 0.1.0-rc.10

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.
Files changed (128) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +79 -2
  3. package/bin/create-feathers-app.mjs +52 -0
  4. package/lib/auth-lab/AUTHENTICATION.md +31 -0
  5. package/lib/auth-lab/RBAC.md +16 -0
  6. package/lib/auth-lab/create-secret.mjs +19 -0
  7. package/lib/auth-lab/rbac-policy.mjs +39 -0
  8. package/lib/auth-lab/rbac.mjs +16 -0
  9. package/lib/auth-lab/schema.mjs +42 -0
  10. package/lib/auth-lab/secrets.mjs +12 -0
  11. package/lib/auth-lab/setup.mjs +121 -0
  12. package/lib/auth-lab/store.mjs +52 -0
  13. package/lib/auth-lab.mjs +91 -0
  14. package/lib/compose.mjs +252 -0
  15. package/lib/persistence-lab/create-secret.mjs +17 -0
  16. package/lib/persistence-lab/notes-service.mjs +68 -0
  17. package/lib/persistence-lab/open.mjs +30 -0
  18. package/lib/persistence-lab/prepare.mjs +27 -0
  19. package/lib/persistence-lab/storage.mjs +27 -0
  20. package/lib/persistence-lab.mjs +135 -0
  21. package/lib/planner/options.mjs +74 -0
  22. package/lib/planner/plan.mjs +107 -0
  23. package/lib/scaffold.mjs +350 -0
  24. package/lib/secure-runtime/connect.mjs +28 -0
  25. package/lib/secure-runtime/policy.mjs +85 -0
  26. package/lib/secure-runtime/prepare-storage.mjs +14 -0
  27. package/lib/secure-runtime/providers.mjs +74 -0
  28. package/lib/secure-runtime/register.mjs +80 -0
  29. package/lib/secure-runtime/storage.mjs +31 -0
  30. package/lib/services.mjs +227 -0
  31. package/lib/single-server/spa-static.mjs +60 -0
  32. package/lib/single-server.mjs +182 -0
  33. package/lib/tenant-lab/notes.mjs +99 -0
  34. package/lib/tenant-lab/schema.mjs +65 -0
  35. package/lib/tenant-lab/scope.mjs +34 -0
  36. package/lib/tenant-lab/store.mjs +53 -0
  37. package/lib/tenant-lab.mjs +81 -0
  38. package/lib/typescript.mjs +148 -0
  39. package/lib/universal/integrations.mjs +23 -0
  40. package/lib/universal/lab.mjs +132 -0
  41. package/package.json +38 -4
  42. package/templates/common/dev-all-quasar-ssr.mjs +37 -0
  43. package/templates/common/dev-all.mjs +46 -0
  44. package/templates/daisyui-ssg/.env.example +6 -0
  45. package/templates/daisyui-ssg/README.md +21 -0
  46. package/templates/daisyui-ssg/api/server.mjs +48 -0
  47. package/templates/daisyui-ssg/api/services/health/health.class.mjs +3 -0
  48. package/templates/daisyui-ssg/api/services/health/health.service.mjs +4 -0
  49. package/templates/daisyui-ssg/api/services/notes/notes.class.mjs +25 -0
  50. package/templates/daisyui-ssg/api/services/notes/notes.service.mjs +10 -0
  51. package/templates/daisyui-ssg/gitignore.template +36 -0
  52. package/templates/daisyui-ssg/index.html +2 -0
  53. package/templates/daisyui-ssg/package.json +39 -0
  54. package/templates/daisyui-ssg/scripts/prerender.mjs +14 -0
  55. package/templates/daisyui-ssg/scripts/serve-static.mjs +32 -0
  56. package/templates/daisyui-ssg/src/App.vue +14 -0
  57. package/templates/daisyui-ssg/src/components/NotesPanel.vue +22 -0
  58. package/templates/daisyui-ssg/src/entry-client.ts +9 -0
  59. package/templates/daisyui-ssg/src/entry-server.ts +9 -0
  60. package/templates/daisyui-ssg/src/stores/notes.ts +46 -0
  61. package/templates/daisyui-ssg/src/style.css +5 -0
  62. package/templates/daisyui-ssg/src/vite-env.d.ts +1 -0
  63. package/templates/daisyui-ssg/tsconfig.json +4 -0
  64. package/templates/daisyui-ssg/vite.config.ts +6 -0
  65. package/templates/daisyui-ssr/.env.example +6 -0
  66. package/templates/daisyui-ssr/README.md +21 -0
  67. package/templates/daisyui-ssr/api/server.mjs +48 -0
  68. package/templates/daisyui-ssr/api/services/health/health.class.mjs +3 -0
  69. package/templates/daisyui-ssr/api/services/health/health.service.mjs +4 -0
  70. package/templates/daisyui-ssr/api/services/notes/notes.class.mjs +25 -0
  71. package/templates/daisyui-ssr/api/services/notes/notes.service.mjs +10 -0
  72. package/templates/daisyui-ssr/gitignore.template +36 -0
  73. package/templates/daisyui-ssr/index.html +2 -0
  74. package/templates/daisyui-ssr/package.json +39 -0
  75. package/templates/daisyui-ssr/scripts/serve-ssr.mjs +47 -0
  76. package/templates/daisyui-ssr/src/App.vue +14 -0
  77. package/templates/daisyui-ssr/src/components/NotesPanel.vue +22 -0
  78. package/templates/daisyui-ssr/src/entry-client.ts +9 -0
  79. package/templates/daisyui-ssr/src/entry-server.ts +9 -0
  80. package/templates/daisyui-ssr/src/stores/notes.ts +46 -0
  81. package/templates/daisyui-ssr/src/style.css +5 -0
  82. package/templates/daisyui-ssr/src/vite-env.d.ts +1 -0
  83. package/templates/daisyui-ssr/tsconfig.json +4 -0
  84. package/templates/daisyui-ssr/vite.config.ts +7 -0
  85. package/templates/quasar-ssr/.env.example +11 -0
  86. package/templates/quasar-ssr/api/server.mjs +48 -0
  87. package/templates/quasar-ssr/api/services/health/health.class.mjs +3 -0
  88. package/templates/quasar-ssr/api/services/health/health.service.mjs +4 -0
  89. package/templates/quasar-ssr/api/services/notes/notes.class.mjs +25 -0
  90. package/templates/quasar-ssr/api/services/notes/notes.service.mjs +10 -0
  91. package/templates/quasar-ssr/gitignore.template +36 -0
  92. package/templates/quasar-ssr/index.html +12 -0
  93. package/templates/quasar-ssr/quasar.config.ts +27 -0
  94. package/templates/quasar-ssr/scripts/edge-proxy.mjs +30 -0
  95. package/templates/quasar-ssr/scripts/edge-routing.mjs +14 -0
  96. package/templates/quasar-ssr/src/App.vue +1 -0
  97. package/templates/quasar-ssr/src/css/app.css +20 -0
  98. package/templates/quasar-ssr/src/pages/IndexPage.vue +146 -0
  99. package/templates/quasar-ssr/src/pages/NotFoundPage.vue +11 -0
  100. package/templates/quasar-ssr/src/router/index.ts +8 -0
  101. package/templates/quasar-ssr/src/router/routes.ts +6 -0
  102. package/templates/quasar-ssr/src/stores/index.ts +4 -0
  103. package/templates/quasar-ssr/src/stores/notes.ts +73 -0
  104. package/templates/quasar-ssr/src-ssr/middlewares/render.ts +15 -0
  105. package/templates/quasar-ssr/src-ssr/server.ts +42 -0
  106. package/templates/quasar-ssr/tsconfig.json +4 -0
  107. package/templates/spa/common/.env.example +8 -0
  108. package/templates/spa/common/api/server.mjs +46 -0
  109. package/templates/spa/common/api/services/health/health.class.mjs +3 -0
  110. package/templates/spa/common/api/services/health/health.service.mjs +4 -0
  111. package/templates/spa/common/api/services/notes/notes.class.mjs +25 -0
  112. package/templates/spa/common/api/services/notes/notes.service.mjs +10 -0
  113. package/templates/spa/common/gitignore.template +39 -0
  114. package/templates/spa/common/index.html +3 -0
  115. package/templates/spa/common/src/components/NotesPanel.vue +25 -0
  116. package/templates/spa/common/src/stores/notes.ts +105 -0
  117. package/templates/spa/common/src/vite-env.d.ts +1 -0
  118. package/templates/spa/common/tsconfig.json +4 -0
  119. package/templates/spa/daisyui/src/App.vue +2 -0
  120. package/templates/spa/daisyui/src/components/NotesPanel.vue +39 -0
  121. package/templates/spa/daisyui/src/main.ts +5 -0
  122. package/templates/spa/daisyui/src/style.css +5 -0
  123. package/templates/spa/daisyui/vite.config.ts +8 -0
  124. package/templates/spa/quasar2/src/App.vue +3 -0
  125. package/templates/spa/quasar2/src/components/NotesPanel.vue +41 -0
  126. package/templates/spa/quasar2/src/main.ts +7 -0
  127. package/templates/spa/quasar2/src/style.css +1 -0
  128. package/templates/spa/quasar2/vite.config.ts +8 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 vite-plugin-feathersjs contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,80 @@
1
- # Temporary Holding Version
1
+ # create-feathers-app
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Générez des applications FeathersJS v5 avec Vue 3, Pinia, Vite, Quasar 2 ou Tailwind CSS + daisyUI.
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.7). La version `0.1.0-rc.10` 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
+
7
+ ## Usage
8
+
9
+ ```powershell
10
+ # Après publication de cette préversion sur npm
11
+ npm exec --yes --package=@vevedh/create-feathers-app@0.1.0-rc.10 -- create-feathers-app portail --ui daisyui --render spa
12
+
13
+ # Candidate RC5, depuis les sources mises en stage :
14
+ cfapp portail-ssr --ui quasar2 --render ssr
15
+ ```
16
+
17
+ | Interface | SPA | SSR | SSG |
18
+ | --- | --- | --- | --- |
19
+ | Quasar 2 | Générable | Générable, validation Windows complète à vérifier | Bloqué |
20
+ | daisyUI 5 | Générable | Générable | Générable |
21
+
22
+ ## PWA optionnelle (SPA uniquement)
23
+
24
+ Le paramètre `--pwa on` ajoute `vite-plugin-pwa` v2 en dépendance de développement, une icône SVG locale de démonstration et un manifeste Web App. Le service worker, désactivé en développement, est généré au build et conserve uniquement des ressources statiques, jamais les réponses `/api` ou les échanges Socket.IO. Par défaut, `--pwa off` ne change pas les templates existants.
25
+
26
+ ```powershell
27
+ cfapp portail-spa --ui quasar2 --render spa --pwa on --dry-run
28
+ ```
29
+
30
+ SSR/SSG avec PWA est refusé tant que leur cycle de vie n'est pas certifié. Pour une installation sur les navigateurs ciblés, remplacer l'icône SVG de démonstration par des icônes PNG 192 et 512 pixels, puis contrôler HTTPS, les mises à jour et le mode hors-ligne.
31
+
32
+ ## Ajouter un service Feathers
33
+
34
+ ```powershell
35
+ cd portail
36
+ node ../chemin/vers/create-feathers-app/bin/create-feathers-app.mjs service add tickets --schema zod --db main
37
+ ```
38
+
39
+ Le générateur ajoute le module `api/services/tickets` et son inscription dans le registre Feathers. Les schémas disponibles sont `typebox`, `json` et `zod`. Le service mémoire est destiné au laboratoire local, avec CRUD REST et validation des données.
40
+
41
+ ## Profils d'identité et de bases
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 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
+
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
+
47
+ ## Windows
48
+
49
+ ```powershell
50
+ corepack pnpm install
51
+ corepack pnpm run typecheck
52
+ corepack pnpm run build
53
+ corepack pnpm run dev:all
54
+ ```
55
+
56
+ SPA et daisyUI SSR/SSG utilisent `http://127.0.0.1:5173`. Quasar SSR utilise `http://127.0.0.1:8080` via un proxy local ; le SSR brut écoute sur 9100.
57
+
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
+
60
+ ## API Feathers intégrée à Vite en développement (RC5)
61
+
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.7` est chargé depuis
64
+ `vite.config.ts` et monte les services sur `/api` pendant `vite dev`.
65
+ Il n'y a pas de second serveur REST ni de proxy REST à démarrer.
66
+
67
+ L'option `--dev-api separate` conserve l'ancien comportement à deux processus.
68
+ Pour SSR/SSG, le mode séparé reste obligatoire. Avec `--transport both`,
69
+ Socket.IO reste sur le port privé `127.0.0.1:3031`, accessible depuis la SPA
70
+ via le proxy WebSocket Vite. La configuration de Feathers échoue au chargement
71
+ si les profils d'identité ou bases externes ne sont pas opérationnels.
72
+
73
+ ```powershell
74
+ cfapp mon-spa --ui quasar2 --render spa --dev-api vite --transport both
75
+ ```
76
+
77
+ **Production** : le plugin Vite s'exécute uniquement en développement.
78
+ Le serveur `api/server.mjs` autonome reste disponible et obligatoire pour
79
+ le déploiement ; `vite preview` n'héberge pas l'API.
80
+ La version `0.2.7` du plugin doit être certifiée et publiée avant une installation depuis npm.
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ import { dirname,resolve } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { readFile } from 'node:fs/promises'
5
+ import { createGenerator } from '../lib/scaffold.mjs'
6
+ import { runServiceCommand } from '../lib/services.mjs'
7
+ const packageRoot=resolve(dirname(fileURLToPath(import.meta.url)),'..')
8
+ const {version}=JSON.parse(await readFile(resolve(packageRoot,'package.json'),'utf8'))
9
+ const args=process.argv.slice(2)
10
+ if(args.includes('--help')||args.includes('-h')||!args.length){
11
+ console.log(`create-feathers-app ${version}
12
+
13
+ Usage: create-feathers-app <name> [options]
14
+ --name <name> Project name (alternative to positional name)
15
+ --dir <directory> Safe folder name (within current directory)
16
+ --ui <quasar2|daisyui> Default daisyui
17
+ --render <spa|ssr|ssg> Default spa
18
+ --adapter <koa|express> Default koa
19
+ --transport <rest|both> Default rest (both only for SPA)
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)
22
+ --dev-api <vite|separate> SPA default vite; SSR/SSG always separate
23
+ --single-server <node|bun> Gate25 opt-in SPA Express isolated JWT universal lab
24
+ --dry-run Preview files without writing
25
+ --json Machine-readable output
26
+ --schema <typebox|json|zod> Service schema preset (default typebox)
27
+ --auth <none|jwt|oauth2|oidc|keycloak> Identity configuration blueprint
28
+ --db <alias:adapter[,alias:adapter]> Named connections (default main:memory)
29
+ --persistence <off|lab> Opt-in private Docker Production DB demonstration
30
+ --tenant-mode <off|isolated-lab> Opt-in per-tenant ACL lab (JWT + persistent DB)
31
+ --compose <off|dev|prod|both|dev-prod> Docker Compose profile (default off)
32
+ --database-mode <single|universal|multi> Default single; multi is blueprint only
33
+ --cache <none|valkey> Cache blueprint (not connected)
34
+ --storage <none|s3-external> Object-storage blueprint (not connected)
35
+ --version Print CLI version
36
+
37
+ Service: create-feathers-app service add <name> [--project <path>] [--schema typebox|json|zod] [--db <alias>] [--auth none|jwt] [--lang js|ts] [--dry-run]
38
+
39
+ Available: Quasar 2 SPA/SSR; daisyUI SPA, SSR, SSG.
40
+ Quasar SSR uses the R16-certified architecture; R17-generated SSR requires its own Windows E2E certification. Quasar SSG not supported.`)
41
+ }else if(args.includes('--version')||args.includes('-v'))console.log(version)
42
+ else if(args[0]==='service') {
43
+ try { await runServiceCommand(args.slice(1),process.cwd()) } catch(error){console.error(`FAIL: ${error.message}`);process.exitCode=1}
44
+ }
45
+ else {
46
+ const {parse,generate}=createGenerator({outputRoot:process.cwd(),templatesRoot:resolve(packageRoot,'templates'),reportRoot:process.cwd()})
47
+ try {
48
+ const options=parse(args)
49
+ const result=await generate(options)
50
+ console.log(options.json?JSON.stringify(result,null,2):`${options.dryRun?'DRY RUN':'CREATED'}: ${result.destination} (${result.files.length} files)`)
51
+ } catch(error){console.error(`FAIL: ${error.message}`);process.exitCode=1}
52
+ }
@@ -0,0 +1,31 @@
1
+ # Authentification Feathers et sessions durables — laboratoire
2
+
3
+ Ce profil utilise **Feathers AuthenticationService**, les stratégies **local + JWT**, un registre de sessions durable dans le provider universel et une authentification **obligatoire** sur le service `notes` (REST et Socket.IO).
4
+
5
+ ## Démarrage
6
+
7
+ Créer les secrets de la base puis de l'authentification, une seule fois :
8
+
9
+ ```powershell
10
+ node scripts/create-lab-secret.mjs
11
+ node scripts/create-auth-secrets.mjs
12
+ corepack pnpm install --lockfile-only
13
+ docker compose -f compose.prod.yaml up --build -d
14
+ ```
15
+
16
+ Les secrets sont enregistrés sous `secrets/`, exclus de Git et des images Docker. Ne jamais afficher ni communiquer leurs valeurs.
17
+
18
+ Le compte de laboratoire interne est `lab-admin@example.invalid`. Son mot de passe initial se trouve dans le fichier privé `secrets/admin_password`. Ne pas l'utiliser en dehors de ce laboratoire ni conserver ce compte en production.
19
+
20
+ ## Contrat HTTP
21
+
22
+ - `POST /api/authentication` avec `{ "strategy":"local", "email":"lab-admin@example.invalid", "password":"<valeur privée>" }` renvoie un JWT de 15 minutes.
23
+ - `Authorization: Bearer <JWT>` est obligatoire pour `/api/notes`.
24
+ - `POST /api/sessions` avec `{ "operation":"revoke" }` révoque le jeton courant ; `{ "operation":"rotate" }` le consomme et renvoie un nouveau jeton.
25
+ - `/api/users` ne propose aucune méthode publique : le service de résolution d'identité reste interne.
26
+
27
+ Les sessions sont stockées dans la même base que les notes, avec une expiration persistante et une transition atomique de révocation. Après redémarrage de l'API, un JWT non révoqué peut être réutilisé jusqu'à son expiration ; un JWT révoqué doit rester refusé. Le mot de passe hashé n'est jamais renvoyé par l'API.
28
+
29
+ ## Limites
30
+
31
+ Laboratoire Docker **loopback uniquement**. RBAC multi-rôles, MFA, protections anti-bruteforce multi-instance, renouvellement de clés, HTTPS public, récupération de compte et gestion du cycle de vie des utilisateurs ne sont pas certifiés. Pas de stockage de JWT en localStorage dans la SPA.
@@ -0,0 +1,16 @@
1
+ # Contrôle des accès RBAC — laboratoire
2
+
3
+ Le service `notes` est protégé par `authenticate('jwt')` puis par une politique côté serveur.
4
+ La politique relit le rôle depuis la base active et vérifie la session durable.
5
+ Un rôle fourni dans le JWT, les paramètres Socket.IO ou la requête est ignoré.
6
+
7
+ | Rôle | Lecture `find/get` | Création `create` | Modification `patch` | Remplacement / suppression |
8
+ | --- | --- | --- | --- | --- |
9
+ | viewer | Oui | Non | Non | Non |
10
+ | editor | Oui | Oui | Oui | Non |
11
+ | admin | Oui | Oui | Oui | Oui |
12
+
13
+ Le service `health` demeure public. Les services `users` sont internes et `sessions` applique déjà une vérification JWT durable.
14
+ La connexion via Socket.IO n'est **pas encore certifiée en E2E** : les tests couvrent la politique et le générateur, pas le réseau réel.
15
+
16
+ Limites : laboratoire local ; aucune isolation multitenant ni ACL par enregistrement. La politique n'autorise pas l'exposition Internet sans audit et tests supplémentaires.
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ import { randomBytes } from 'node:crypto'
3
+ import { mkdir, open, lstat } from 'node:fs/promises'
4
+ import { join, resolve } from 'node:path'
5
+ const root = resolve('secrets')
6
+ try {
7
+ if (process.argv.length !== 2) throw Error('NO_ARGUMENTS_ALLOWED')
8
+ await mkdir(root,{recursive:true,mode:0o700})
9
+ if ((await lstat(root)).isSymbolicLink()) throw Error('SECRET_DIRECTORY_REFUSED')
10
+ const entries = [['jwt_secret',48], ['admin_password',32]]
11
+ const existing = await Promise.all(entries.map(async ([name]) => lstat(join(root,name)).then(()=>true,e=>{if(e.code==='ENOENT')return false;throw e})))
12
+ if (existing.every(Boolean)) { console.log('AUTH_SECRETS=EXISTING | CONTENT=REDACTED'); process.exit(0) }
13
+ if (existing.some(Boolean)) throw Error('PARTIAL_SECRET_SET_REFUSED')
14
+ for (const [name,size] of entries) {
15
+ const file = await open(join(root,name),'wx',0o600)
16
+ try { await file.writeFile(randomBytes(size).toString('base64url')+'\n') } finally { await file.close() }
17
+ }
18
+ console.log('AUTH_SECRETS=CREATED | CONTENT=REDACTED')
19
+ } catch { console.error('AUTH_SECRETS=REFUSED'); process.exitCode=2 }
@@ -0,0 +1,39 @@
1
+ /** One provider per deployment, roles reloaded from the server-side store on each request. */
2
+ export const NOTES_PERMISSIONS = Object.freeze({
3
+ viewer: Object.freeze(['find', 'get']),
4
+ editor: Object.freeze(['find', 'get', 'create', 'patch']),
5
+ admin: Object.freeze(['find', 'get', 'create', 'update', 'patch', 'remove']),
6
+ })
7
+
8
+ const UUID = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i
9
+ const ROLES = new Set(Object.keys(NOTES_PERMISSIONS))
10
+ const METHODS = new Set(['find', 'get', 'create', 'update', 'patch', 'remove'])
11
+
12
+ export function canAccessNotes(role, method) {
13
+ return typeof role === 'string' && typeof method === 'string'
14
+ && ROLES.has(role) && METHODS.has(method) && NOTES_PERMISSIONS[role].includes(method)
15
+ }
16
+
17
+ /**
18
+ * Called ONLY AFTER the Feathers authenticate('jwt') before hook.
19
+ * Never grant access using a role from JWT claims, query, request body or socket metadata.
20
+ * An internal service call without a documented server authority is denied as well.
21
+ */
22
+ export async function evaluateNotesAccess(context, store) {
23
+ if (!context || typeof context !== 'object' || context.path !== 'notes'
24
+ || !METHODS.has(context.method) || !store || typeof store.getById !== 'function'
25
+ || typeof store.validSession !== 'function') return false
26
+ const params = context.params
27
+ if (!params || !['rest', 'socketio'].includes(params.provider)) return false
28
+ const provided = params.user
29
+ const payload = params.authentication?.payload
30
+ if (!provided || !UUID.test(provided.id) || !payload || !UUID.test(payload.sid)
31
+ || payload.sub !== provided.id || !Number.isSafeInteger(payload.ver)
32
+ || payload.ver < 1) return false
33
+ // The authenticated user is only an identity pointer: roles come from the database.
34
+ const authoritative = await store.getById(provided.id)
35
+ if (!authoritative || authoritative.id !== provided.id || authoritative.status !== 'active'
36
+ || !Number.isSafeInteger(authoritative.sessionVersion)
37
+ || authoritative.sessionVersion !== payload.ver || !canAccessNotes(authoritative.role,context.method)) return false
38
+ return Boolean(await store.validSession(payload.sid, authoritative.id, authoritative.sessionVersion))
39
+ }
@@ -0,0 +1,16 @@
1
+ import { Forbidden } from '@feathersjs/errors'
2
+ import { evaluateNotesAccess } from './auth-rbac-policy.mjs'
3
+
4
+ export function createNotesRbacHook(store) {
5
+ if (!store || typeof store.getById !== 'function' || typeof store.validSession !== 'function') {
6
+ throw new TypeError('Trusted RBAC store required')
7
+ }
8
+ return async function authorizeNotes(context) {
9
+ try {
10
+ if (await evaluateNotesAccess(context, store)) return context
11
+ } catch {
12
+ // Provider errors and identity details are never returned to clients.
13
+ }
14
+ throw new Forbidden('Forbidden')
15
+ }
16
+ }
@@ -0,0 +1,42 @@
1
+ const users = 'app_auth_users'
2
+ const sessions = 'app_auth_sessions'
3
+ // Additive only. Never remove tables, columns or MongoDB collections.
4
+ export async function prepareAuthSchema(provider) {
5
+ if (!provider || !['postgresql','mariadb','mongodb'].includes(provider.adapter)) throw Error('AUTH_PROVIDER_REQUIRED')
6
+ const db = provider.db
7
+ if (provider.adapter === 'mongodb') {
8
+ for (const name of [users,sessions]) {
9
+ try { await db.createCollection(name) } catch (error) { if (error?.code !== 48) throw error }
10
+ await db.collection(name).createIndex({ id: 1 }, { unique: true })
11
+ }
12
+ await db.collection(users).createIndex({ email: 1 }, { unique: true })
13
+ await db.collection(sessions).createIndex({ user_id: 1, expires_at: 1 })
14
+ return
15
+ }
16
+ if (!await db.schema.hasTable(users)) await db.schema.createTable(users, table => {
17
+ table.string('id', 36).primary()
18
+ table.string('email', 190).notNullable().unique()
19
+ table.string('password', 255).notNullable()
20
+ table.string('status', 16).notNullable()
21
+ table.string('role', 16).notNullable()
22
+ table.integer('session_version').notNullable()
23
+ })
24
+ if (!await db.schema.hasTable(sessions)) await db.schema.createTable(sessions, table => {
25
+ table.string('id', 36).primary()
26
+ table.string('user_id', 36).notNullable().index()
27
+ table.integer('session_version').notNullable()
28
+ table.bigInteger('expires_at').notNullable()
29
+ table.boolean('revoked').notNullable().defaultTo(false)
30
+ })
31
+ for (const [name, columns] of [[users,['id','email','password','status','role','session_version']], [sessions,['id','user_id','session_version','expires_at','revoked']]]) {
32
+ for (const field of columns) if (!await db.schema.hasColumn(name,field)) throw Error('AUTH_SCHEMA_MIGRATION_REQUIRED')
33
+ }
34
+ }
35
+ export async function checkAuthSchema(provider) {
36
+ if (!provider || !['postgresql','mariadb','mongodb'].includes(provider.adapter)) throw Error('AUTH_PROVIDER_REQUIRED')
37
+ if (provider.adapter === 'mongodb') {
38
+ for (const name of [users,sessions]) if (!await provider.db.listCollections({ name }).hasNext()) throw Error('AUTH_SCHEMA_NOT_READY')
39
+ } else {
40
+ for (const name of [users,sessions]) if (!await provider.db.schema.hasTable(name)) throw Error('AUTH_SCHEMA_NOT_READY')
41
+ }
42
+ }
@@ -0,0 +1,12 @@
1
+ import { readFile, lstat } from 'node:fs/promises'
2
+ const allowed = Object.freeze({jwt:'/run/secrets/p5_jwt_secret',admin:'/run/secrets/p5_admin_password'})
3
+ export async function readAuthSecret(kind) {
4
+ const wanted = allowed[kind]
5
+ const actual = kind === 'jwt' ? process.env.P5_JWT_SECRET_FILE : process.env.P5_ADMIN_PASSWORD_FILE
6
+ if (!wanted || wanted !== actual) throw Error('AUTH_SECRET_PATH_REFUSED')
7
+ const stat = await lstat(actual)
8
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size < 32 || stat.size > 256) throw Error('AUTH_SECRET_INVALID')
9
+ const secret = (await readFile(actual,'utf8')).replace(/\r?\n$/, '')
10
+ if (secret.length < 32 || secret.length > 256 || /[\u0000-\u001f\u007f]/.test(secret)) throw Error('AUTH_SECRET_INVALID')
11
+ return secret
12
+ }
@@ -0,0 +1,121 @@
1
+ import { AuthenticationService, JWTStrategy, authenticate } from '@feathersjs/authentication'
2
+ import { LocalStrategy } from '@feathersjs/authentication-local'
3
+ import { NotAuthenticated, BadRequest } from '@feathersjs/errors'
4
+ import { createAuthStore } from './auth-store.mjs'
5
+ import { checkAuthSchema } from './auth-schema.mjs'
6
+ import { createNotesRbacHook } from './auth-rbac.mjs'
7
+
8
+ const ADMIN_EMAIL = 'lab-admin@example.invalid'
9
+ const UUID = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i
10
+ function safeUser(value) {
11
+ if (!value || typeof value !== 'object' || !UUID.test(value.id) || value.status !== 'active' || !['viewer','editor','admin'].includes(value.role) || !Number.isSafeInteger(value.sessionVersion)) throw new NotAuthenticated('Identity unavailable')
12
+ return { id:value.id, role:value.role, status:value.status, sessionVersion:value.sessionVersion }
13
+ }
14
+ export async function configureJwtLab(app, provider, secret) {
15
+ if (process.env.NODE_ENV !== 'production' || process.env.P5_AUTH_MODE !== 'jwt-lab' || typeof secret !== 'string' || secret.length < 32) throw Error('AUTH_PROFILE_REFUSED')
16
+ await checkAuthSchema(provider)
17
+ const store = createAuthStore(provider)
18
+ const usersService = {
19
+ id: 'id',
20
+ async get(id) { const user = await store.getById(id); if (!user) throw new NotAuthenticated('Identity unavailable'); return user },
21
+ async find(params) {
22
+ const query = params?.query
23
+ if (!query || Object.keys(query).some(key => !['email','$limit'].includes(key)) || typeof query.email !== 'string') throw new BadRequest('Query refused')
24
+ const user = await store.getByEmail(query.email)
25
+ return user ? [user] : []
26
+ },
27
+ }
28
+ // No transport methods: users are resolved internally by Feathers strategies.
29
+ app.use('users', usersService, { methods: [] })
30
+ app.set('authentication', {
31
+ secret,
32
+ entity: 'user', service: 'users',
33
+ authStrategies: ['local', 'jwt'], parseStrategies: ['jwt'],
34
+ jwtOptions: { header:{typ:'access'}, audience:'p5-jwt-lab', issuer:'p5-jwt-lab', algorithm:'HS256', expiresIn:'15m' },
35
+ local: { usernameField:'email', passwordField:'password' },
36
+ })
37
+ class SafeLocalStrategy extends LocalStrategy {
38
+ // Feathers LocalStrategy otherwise re-reads the user with a provider-tagged
39
+ // get() call after password verification. The users service has no public
40
+ // methods: return the verified internal record without opening that route.
41
+ async getEntity(result) { return safeUser(result) }
42
+ async authenticate(data, params) {
43
+ const result = await super.authenticate(data, params)
44
+ return { ...result, user: safeUser(result.user) }
45
+ }
46
+ }
47
+ class DurableJWTStrategy extends JWTStrategy {
48
+ // JWTStrategy's default external getEntity() repeats get() with provider=rest.
49
+ // Resolve server-side instead: the users Feathers service remains internal.
50
+ async getEntity(id) { return safeUser(await store.getById(id)) }
51
+ async authenticate(data, params) {
52
+ const result = await super.authenticate(data, params)
53
+ const user = safeUser(result.user)
54
+ const payload = result.authentication?.payload
55
+ if (!payload || !UUID.test(payload.sid) || payload.ver !== user.sessionVersion || !await store.validSession(payload.sid,user.id,user.sessionVersion)) throw new NotAuthenticated('Session unavailable')
56
+ return { ...result, user }
57
+ }
58
+ }
59
+ class DurableAuthService extends AuthenticationService {
60
+ async getPayload(authResult, params) {
61
+ // JWT reauthentication must never mint a new durable session.
62
+ if (authResult?.authentication?.strategy === 'jwt') throw new NotAuthenticated('JWT renewal refused')
63
+ const payload = await super.getPayload(authResult,params)
64
+ const user = safeUser(authResult.user)
65
+ const sid = await store.createSession(user.id,user.sessionVersion)
66
+ return { ...payload, sid, ver:user.sessionVersion }
67
+ }
68
+ async create(data, params) {
69
+ if (!data || typeof data !== 'object' || Array.isArray(data)) throw new NotAuthenticated('Invalid login')
70
+ if (data.strategy === 'jwt') {
71
+ // Existing token only: a socket reconnect is not a login/refresh flow.
72
+ if (Object.keys(data).length !== 2 || !Object.hasOwn(data,'accessToken') || !Object.hasOwn(data,'strategy')
73
+ || typeof data.accessToken !== 'string' || data.accessToken.length < 40 || data.accessToken.length > 4096
74
+ || !/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(data.accessToken)) throw new NotAuthenticated('Invalid JWT reauthentication')
75
+ const result = await super.create(data,params)
76
+ // Feathers JWTStrategy returns its input token unchanged; fail closed if that changes.
77
+ if (result.accessToken !== data.accessToken) throw new NotAuthenticated('Unexpected JWT renewal')
78
+ return { accessToken:data.accessToken, authentication:{strategy:'jwt'}, user:safeUser(result.user) }
79
+ }
80
+ if (data.strategy !== 'local' || Object.keys(data).some(key=>!['strategy','email','password'].includes(key)) || typeof data.email !== 'string' || data.email.length > 190 || typeof data.password !== 'string' || data.password.length > 256) throw new NotAuthenticated('Invalid login')
81
+ const result = await super.create(data,params)
82
+ return { accessToken:result.accessToken, authentication:{strategy:'local'}, user:safeUser(result.user) }
83
+ }
84
+ }
85
+ const auth = new DurableAuthService(app)
86
+ auth.register('jwt',new DurableJWTStrategy())
87
+ auth.register('local',new SafeLocalStrategy())
88
+ app.use('authentication',auth,{methods:['create']})
89
+ const sessionsService = {
90
+ async create(data, params) {
91
+ if (!data || typeof data !== 'object' || Array.isArray(data) || Object.keys(data).length !== 1 || !['revoke','rotate'].includes(data.operation)) throw new BadRequest('Invalid session operation')
92
+ const user = safeUser(params.user)
93
+ const sid = params.authentication?.payload?.sid
94
+ if (!UUID.test(sid) || !await store.consumeSession(sid,user.id,user.sessionVersion)) throw new NotAuthenticated('Session unavailable')
95
+ if (data.operation === 'revoke') return { revoked:true }
96
+ const replacement = await store.createSession(user.id,user.sessionVersion)
97
+ const accessToken = await auth.createAccessToken({sid:replacement,ver:user.sessionVersion},{subject:user.id})
98
+ return { accessToken, rotated:true }
99
+ },
100
+ }
101
+ app.use('sessions',sessionsService,{methods:['create']})
102
+ app.service('sessions').hooks({ before:{ create:[authenticate('jwt')] } })
103
+ app.set('p5AuthStore',store)
104
+ app.set('p5AuthConfigured',true)
105
+ }
106
+ export function protectNotesWithJwt(app) {
107
+ if (app.get('p5AuthConfigured') !== true) throw Error('AUTH_NOT_CONFIGURED')
108
+ app.service('notes').hooks({before:{all:[authenticate('jwt'),createNotesRbacHook(app.get('p5AuthStore'))]}})
109
+ // Fail closed for server-to-client events. RPC responses still work.
110
+ // Private per-record/tenant channels require their own future certification.
111
+ for (const name of ['notes','sessions','authentication']) {
112
+ const service = app.service(name)
113
+ if (!service) throw Error('AUTH_EVENT_SERVICE_MISSING')
114
+ if (typeof service.publish === 'function') service.publish(() => null)
115
+ else if (typeof app.publish === 'function') throw Error('AUTH_EVENT_PUBLISHER_MISSING')
116
+ }
117
+ // Authentication login/logout are application-level EventEmitter notifications,
118
+ // not publishable service events. Feathers handles them internally; no
119
+ // authentication data is broadcast by the service-level publishers above.
120
+ }
121
+ export const bootstrapAdminEmail = ADMIN_EMAIL
@@ -0,0 +1,52 @@
1
+ import { randomUUID } from 'node:crypto'
2
+
3
+ // Server-only durable identity and session repository. SQL queries are parameterized
4
+ // by Knex; MongoDB receives closed, validated field sets. No transport-specific data.
5
+ const users = 'app_auth_users'
6
+ const sessions = 'app_auth_sessions'
7
+ const isUUID = value => typeof value === 'string' && /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(value)
8
+ const now = () => Date.now()
9
+ export function createAuthStore(provider) {
10
+ if (!provider || !['postgresql','mariadb','mongodb'].includes(provider.adapter)) throw Error('AUTH_PROVIDER_REQUIRED')
11
+ const mongo = provider.adapter === 'mongodb'
12
+ const sql = provider.db
13
+ const userColl = mongo ? sql.collection(users) : null
14
+ const sessionColl = mongo ? sql.collection(sessions) : null
15
+ const convert = row => row ? Object.freeze({ id: row.id, email: row.email, password: row.password, status: row.status, role: row.role, sessionVersion: row.session_version ?? row.sessionVersion }) : null
16
+ const getById = async id => isUUID(id) ? convert(mongo ? await userColl.findOne({ id }) : await sql(users).where({ id }).first()) : null
17
+ const getByEmail = async email => typeof email === 'string' && email.length <= 190
18
+ ? convert(mongo ? await userColl.findOne({ email }) : await sql(users).where({ email }).first()) : null
19
+ async function seedAdmin(email, hashed) {
20
+ if (typeof email !== 'string' || !/^[a-z0-9.-]{1,80}@[a-z0-9.-]{1,70}$/.test(email) || typeof hashed !== 'string' || !/^\$2[aby]\$/.test(hashed)) throw Error('AUTH_BOOTSTRAP_INVALID')
21
+ if (await getByEmail(email)) return false
22
+ const record = { id: randomUUID(), email, password: hashed, status: 'active', role: 'admin' }
23
+ if (mongo) await userColl.insertOne({ ...record, sessionVersion: 1 })
24
+ else await sql(users).insert({ ...record, session_version: 1 })
25
+ return true
26
+ }
27
+ async function createSession(userId, version, ttlMs = 900000) {
28
+ if (!isUUID(userId) || !Number.isSafeInteger(version) || version < 1 || ttlMs !== 900000) throw Error('AUTH_SESSION_INVALID')
29
+ const value = { id: randomUUID(), user_id: userId, session_version: version, expires_at: now() + ttlMs, revoked: false }
30
+ if (mongo) await sessionColl.insertOne(value)
31
+ else await sql(sessions).insert(value)
32
+ return value.id
33
+ }
34
+ async function validSession(id, userId, version) {
35
+ if (!isUUID(id) || !isUUID(userId) || !Number.isSafeInteger(version)) return false
36
+ const row = mongo
37
+ ? await sessionColl.findOne({ id, user_id: userId, session_version: version, revoked: false, expires_at: { $gt: now() } })
38
+ : await sql(sessions).where({ id, user_id: userId, session_version: version, revoked: false }).where('expires_at', '>', now()).first()
39
+ return Boolean(row)
40
+ }
41
+ async function consumeSession(id, userId, version) {
42
+ if (!isUUID(id) || !isUUID(userId) || !Number.isSafeInteger(version)) return false
43
+ const filter = { id, user_id: userId, session_version: version, revoked: false }
44
+ if (mongo) {
45
+ const result = await sessionColl.updateOne({ ...filter, expires_at: { $gt: now() } }, { $set: { revoked: true } })
46
+ return result.modifiedCount === 1
47
+ }
48
+ const changed = await sql(sessions).where(filter).where('expires_at', '>', now()).update({ revoked: true })
49
+ return changed === 1
50
+ }
51
+ return Object.freeze({ getById, getByEmail, seedAdmin, createSession, validSession, consumeSession })
52
+ }
@@ -0,0 +1,91 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ const content = name => readFile(new URL(`./auth-lab/${name}`, import.meta.url),'utf8')
3
+ function replaceOnce(files,path,needle,withText) {
4
+ const value = files.get(path)
5
+ if (typeof value !== 'string' || value.split(needle).length !== 2) throw Error('AUTH_TEMPLATE_DRIFT: '+path)
6
+ files.set(path,value.replace(needle,withText))
7
+ }
8
+ export async function enableJwtSessionLab(files,options) {
9
+ if (options.persistence !== 'lab' || options.compose !== 'prod' || options.auth !== 'jwt' || options.render !== 'spa' || options.databases.length !== 1 || !['postgresql','mariadb','mongodb'].includes(options.databases[0].adapter)) throw Error('JWT_LAB_PROFILE_REFUSED')
10
+ for (const [name,target] of [
11
+ ['store.mjs','api/runtime/auth-store.mjs'],
12
+ ['schema.mjs','api/runtime/auth-schema.mjs'],
13
+ ['secrets.mjs','api/runtime/auth-secrets.mjs'],
14
+ ['setup.mjs','api/runtime/auth-setup.mjs'],
15
+ ['rbac-policy.mjs','api/runtime/auth-rbac-policy.mjs'],
16
+ ['rbac.mjs','api/runtime/auth-rbac.mjs'],
17
+ ['create-secret.mjs','scripts/create-auth-secrets.mjs'],
18
+ ]) files.set(target,await content(name))
19
+ replaceOnce(files,'api/runtime/open.mjs','return { adapter, close: () => providers.close() }','return { adapter, provider: providers.get(declaration.alias), close: () => providers.close() }')
20
+ // The database migration runs in the one-shot, private Docker job; secrets
21
+ // never enter generated code, images, environment values or browser bundles.
22
+ const prepImports = `import { prepareAuthSchema } from '../api/runtime/auth-schema.mjs'\nimport { createAuthStore } from '../api/runtime/auth-store.mjs'\nimport { readAuthSecret } from '../api/runtime/auth-secrets.mjs'\nimport { hash } from 'bcryptjs'\n`
23
+ replaceOnce(files,'scripts/prepare-notes.mjs',"import { readFile, lstat } from 'node:fs/promises'\n", "import { readFile, lstat } from 'node:fs/promises'\n"+prepImports)
24
+ replaceOnce(files,'scripts/prepare-notes.mjs'," await prepareNotesStorage(providers.get(declaration.alias))\n console.log('P5_DB_INIT=PASS | NOTES_SCHEMA=READY | NO_SEED=YES')", ` const provider = providers.get(declaration.alias)
25
+ await prepareNotesStorage(provider)
26
+ if (process.env.P5_AUTH_MODE !== 'jwt-lab') throw Error('AUTH_MODE_REFUSED')
27
+ await prepareAuthSchema(provider)
28
+ const adminPassword = await readAuthSecret('admin')
29
+ await createAuthStore(provider).seedAdmin('lab-admin@example.invalid',await hash(adminPassword,12))
30
+ console.log('P5_AUTH_INIT=PASS | USERS=INTERNAL | SESSIONS=DURABLE')
31
+ console.log('P5_DB_INIT=PASS | NOTES_SCHEMA=READY')`)
32
+ const server = files.get('api/server.mjs')
33
+ if (typeof server !== 'string' || !server.includes("configure: app => app.set('p5PersistentNotes', new PersistentNotesService(persistence.adapter)),")) throw Error('AUTH_BOOTSTRAP_DRIFT')
34
+ files.set('api/server.mjs',`import { configureJwtLab, protectNotesWithJwt } from './runtime/auth-setup.mjs'\nimport { readAuthSecret } from './runtime/auth-secrets.mjs'\n`+server.replace('const runtime = await createFeathersRuntime(modules.map(String), file => import(file), {',`const jwtSecret = await readAuthSecret('jwt')
35
+ const runtime = await createFeathersRuntime(modules.map(String), file => import(file), {`).replace("configure: app => app.set('p5PersistentNotes', new PersistentNotesService(persistence.adapter)),",`configure: async app => {
36
+ app.set('p5PersistentNotes',new PersistentNotesService(persistence.adapter))
37
+ await configureJwtLab(app,persistence.provider,jwtSecret)
38
+ },
39
+ afterRegister: app => protectNotesWithJwt(app),`))
40
+ let prod = files.get('compose.prod.yaml')
41
+ if (typeof prod !== 'string' || !prod.includes(' migrate:\n') || !prod.includes(' db:\n') || prod.split(' P5_PERSISTENCE_MODE: notes-lab\n').length !== 3) throw Error('AUTH_COMPOSE_DRIFT')
42
+ prod = prod.replaceAll(' P5_PERSISTENCE_MODE: notes-lab\n',' P5_PERSISTENCE_MODE: notes-lab\n P5_AUTH_MODE: jwt-lab\n')
43
+ const migratePos = prod.indexOf('\n migrate:\n') + 1
44
+ const dbPos = prod.indexOf('\n db:\n', migratePos) + 1
45
+ if (dbPos < 0) throw Error('AUTH_COMPOSE_DRIFT')
46
+ let api = prod.slice(0,migratePos)
47
+ let migrate = prod.slice(migratePos,dbPos)
48
+ const dbRest=prod.slice(dbPos)
49
+ const needle=' - p5_database_password\n'
50
+ if (api.split(needle).length !== 2 || migrate.split(needle).length !== 2) throw Error('AUTH_COMPOSE_SECRET_DRIFT')
51
+ api=api.replace(needle,needle+' - p5_jwt_secret\n')
52
+ migrate=migrate.replace(needle,needle+' - p5_admin_password\n')
53
+ api=api.replace(' P5_AUTH_MODE: jwt-lab\n',' P5_AUTH_MODE: jwt-lab\n P5_JWT_SECRET_FILE: /run/secrets/p5_jwt_secret\n')
54
+ migrate=migrate.replace(' P5_AUTH_MODE: jwt-lab\n',' P5_AUTH_MODE: jwt-lab\n P5_ADMIN_PASSWORD_FILE: /run/secrets/p5_admin_password\n')
55
+ prod=api+migrate+dbRest
56
+ if (!prod.endsWith(' file: ./secrets/db_password\n')) throw Error('AUTH_COMPOSE_SECRET_DRIFT')
57
+ prod += ' p5_jwt_secret:\n file: ./secrets/jwt_secret\n p5_admin_password:\n file: ./secrets/admin_password\n'
58
+ files.set('compose.prod.yaml',prod)
59
+ // Limit brute-force attempts on the sole local login route.
60
+ const nginx=files.get('infra/compose/nginx.conf')
61
+ if (typeof nginx !== 'string' || !nginx.startsWith('server {\n') || !nginx.includes(' location /api/ {')) throw Error('AUTH_NGINX_DRIFT')
62
+ files.set('infra/compose/nginx.conf', 'limit_req_zone $binary_remote_addr zone=p5_auth:10m rate=6r/m;\n'+nginx.replace(' location /api/ {',` limit_req_status 429;
63
+ location = /api/authentication {
64
+ limit_req zone=p5_auth burst=6 nodelay;
65
+ proxy_pass http://api:3030/api/authentication;
66
+ proxy_http_version 1.1;
67
+ proxy_set_header Host $host;
68
+ proxy_set_header X-Forwarded-Proto $scheme;
69
+ proxy_set_header X-Forwarded-For $remote_addr;
70
+ proxy_read_timeout 30s;
71
+ client_max_body_size 8k;
72
+ }
73
+ location /api/ {`))
74
+ const manifest=JSON.parse(files.get('package.json'))
75
+ manifest.dependencies['@feathersjs/authentication']='5.0.50'
76
+ manifest.dependencies['@feathersjs/authentication-local']='5.0.50'
77
+ manifest.dependencies.bcryptjs='3.0.3'
78
+ manifest.scripts['auth:secrets:lab']='node scripts/create-auth-secrets.mjs'
79
+ files.set('package.json',JSON.stringify(manifest,null,2)+'\n')
80
+ files.set('AUTHENTICATION.md',await content('AUTHENTICATION.md'))
81
+ files.set('RBAC.md',await content('RBAC.md'))
82
+ files.set('api/contracts/rbac.ts', `export type NotesRole = 'viewer' | 'editor' | 'admin'\nexport type NotesMethod = 'find' | 'get' | 'create' | 'update' | 'patch' | 'remove'\nexport type NotesPermissions = Readonly<Record<NotesRole, readonly NotesMethod[]>>\n`)
83
+ const old=files.get('PERSISTENCE.md')
84
+ if (typeof old!=='string')throw Error('AUTH_DOC_DRIFT')
85
+ files.set('PERSISTENCE.md',old+'\n## Identité et sessions\n\nLe profil JWT dispose du guide [AUTHENTICATION.md](AUTHENTICATION.md). Ne pas exposer en dehors du laboratoire loopback.\n')
86
+ files.set('api/contracts/durable-auth.ts',`export type UserRole = 'viewer' | 'editor' | 'admin'
87
+ export interface PublicUser { readonly id: string; readonly role: UserRole; readonly status: 'active'; readonly sessionVersion: number }
88
+ export type AuthSessionAction = Readonly<{ operation: 'revoke' | 'rotate' }>
89
+ export type AuthSessionResult = Readonly<{ revoked: true } | { rotated: true; accessToken: string }>
90
+ `)
91
+ }