@vevedh/create-feathers-app 0.1.0-rc.4 → 0.1.0-rc.5

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.
@@ -26,7 +26,7 @@ Usage: create-feathers-app <name> [options]
26
26
  --db <alias:adapter[,alias:adapter]> Named connections (default main:memory)
27
27
  --version Print CLI version
28
28
 
29
- Service: create-feathers-app service add <name> [--project <path>] [--schema typebox|json|zod] [--db <alias>] [--auth none|jwt] [--dry-run]
29
+ Service: create-feathers-app service add <name> [--project <path>] [--schema typebox|json|zod] [--db <alias>] [--auth none|jwt] [--lang js|ts] [--dry-run]
30
30
 
31
31
  Available: Quasar 2 SPA/SSR; daisyUI SPA, SSR, SSG.
32
32
  Quasar SSR uses the R16-certified architecture; R17-generated SSR requires its own Windows E2E certification. Quasar SSG not supported.`)
package/lib/scaffold.mjs CHANGED
@@ -94,6 +94,7 @@ function enableSpaViteRuntime(files, options) {
94
94
  watchDirectories: ['api/services'],
95
95
  adapter: ${JSON.stringify(options.adapter)},
96
96
  prefix: '/api',
97
+ lifecycle: 'managed',
97
98
  cors: false,
98
99
  socketio: ${socket},
99
100
  })`
@@ -201,7 +202,7 @@ export function createGenerator({outputRoot, templatesRoot, examplesRoot = templ
201
202
  if(files.has('gitignore.template')){files.set('.gitignore',files.get('gitignore.template'));files.delete('gitignore.template')}
202
203
  if(files.has('.gitignore')) {
203
204
  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']
205
+ 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
206
  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
207
  }
207
208
  if(!files.has('api/server.mjs')||!files.has('.env.example')||!files.has('index.html'))throw new Error('Incomplete source template')
@@ -249,9 +250,26 @@ export function createGenerator({outputRoot, templatesRoot, examplesRoot = templ
249
250
  if(o.auth==='oauth2')manifest.dependencies['@feathersjs/authentication-oauth']='^5.0.50'
250
251
  for(const db of o.databases) {
251
252
  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]||='*'
253
+ for(const dep of drivers[db.adapter]||[])manifest.dependencies[dep] ||= dep === 'mongodb' ? '6.19.0' : dep === '@feathersjs/mongodb' ? '5.0.51' : '*'
253
254
  }
254
255
  files.set('package.json',JSON.stringify(manifest,null,2)+'\n')
256
+ if (o.auth === 'jwt' && o.databases.some(db => ['postgresql','mariadb','mongodb'].includes(db.adapter))) {
257
+ for (const name of ['register.mjs','policy.mjs']) files.set('api/security/' + name, await readFile(new URL('./secure-runtime/' + name, import.meta.url), 'utf8'))
258
+ Object.assign(manifest.dependencies, { '@feathersjs/authentication-local': '5.0.50', '@casl/ability': '6.8.1', 'feathers-casl': '3.1.0' })
259
+ manifest.dependencies.zod ||= '^4.1.0'
260
+ files.set('package.json',JSON.stringify(manifest,null,2)+'\n')
261
+ }
262
+ const persistentDatabases = o.databases.filter(db => ['postgresql','mariadb','mongodb'].includes(db.adapter))
263
+ if (o.auth === 'jwt' && persistentDatabases.length) {
264
+ const declarations = persistentDatabases.map(db => ({ alias: db.alias, adapter: db.adapter, envPrefix: 'P5_DB_' + db.alias.replaceAll('-', '_').toUpperCase() }))
265
+ if (new Set(declarations.map(db => db.envPrefix)).size !== declarations.length) throw new Error('Database environment prefix collision')
266
+ files.set('api/config/database-connections.generated.mjs', 'export const databaseDeclarations = Object.freeze(' + JSON.stringify(declarations, null, 2) + ')\n')
267
+ 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']]) {
268
+ files.set(target, await readFile(new URL('./secure-runtime/' + source, import.meta.url), 'utf8'))
269
+ }
270
+ 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='])
271
+ files.set('database.env.example', env.join('\n') + '\n')
272
+ }
255
273
  const list=[...files.keys()].sort()
256
274
  const result={name:o.name,ui:o.ui,render:o.render,adapter:o.adapter,transport:o.transport,
257
275
  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}
@@ -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
- const def=schema==='typebox'?`import { Type } from '@feathersjs/typebox'
42
- export const dataSchema=Type.Object({title:Type.String({minLength:1,maxLength:120})},{additionalProperties:false})
43
- `:`export const dataSchema={type:'object',additionalProperties:false,required:['title'],properties:{title:{type:'string',minLength:1,maxLength:120}}}
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
- `+def+`const valid=new Ajv({allErrors:true}).compile(dataSchema)
47
- export function validateData(value){if(!valid(value))throw new BadRequest('Invalid service data');return {title:value.title.trim()}}
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){const n=Number(value);if(!Number.isSafeInteger(n)||n<1)throw new BadRequest('Invalid ID');return n}
57
- async find(){return [...this.#items.values()].map(x=>({...x}))}
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++,...validateData(data)};this.#items.set(row.id,row);return {...row}}
60
- async patch(id,data){const key=this.#id(id);if(!this.#items.has(key))throw new NotFound('Not found');const row={id:key,...validateData(data)};this.#items.set(key,row);return {...row}}
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
- export async function generateService(opts, cwd=process.cwd()){
73
- const project=resolve(cwd,opts.project)
74
- if(opts.project!=='.' && !project.startsWith(resolve(cwd)+sep))throw new Error('Project escapes current directory')
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
- const manifest=JSON.parse(await readFile(manifestPath,'utf8'))
79
- const profile=JSON.parse(await readFile(profilePath,'utf8'))
80
- const schema=opts.schema??profile.schema,auth=opts.auth??profile.auth,db=opts.db??profile.primary
81
- if(!VALID_SCHEMA.has(schema))throw new Error('Invalid schema')
82
- if(!VALID_AUTH.has(auth))throw new Error('Service supports none|jwt; OAuth2/OIDC/Keycloak require audited identity integration')
83
- if(auth!=='none' && profile.auth!==auth)throw new Error('Service auth must match configured project profile')
84
- const connection=profile.databases.find(x=>x.alias===db)
85
- if(!connection)throw new Error('Unknown database alias: '+db)
86
- const destination=join(project,'api/services',opts.name)
87
- await rejectLinks(destination)
88
- if(existsSync(destination))throw new Error('Service already exists; refusing overwrite')
89
- const text=await readFile(serverPath,'utf8')
90
- const marker='const modules = [\n'
91
- if(!text.includes(marker))throw new Error('Missing Feathers modules registry; no changes made')
92
- const moduleEntry=` new URL('./services/${opts.name}/${opts.name}.service.mjs', import.meta.url),\n`
93
- if(text.includes(moduleEntry))throw new Error('Duplicate service registration')
94
- // Never replace a user-controlled arbitrary string outside of the explicit registry.
95
- const output={name:opts.name,project,database:{alias:db,adapter:connection.adapter},auth,schema,files:[`api/services/${opts.name}/schema.mjs`,`api/services/${opts.name}/service.class.mjs`,`api/services/${opts.name}/${opts.name}.service.mjs`],ready:connection.adapter==='memory'&&auth==='none'}
96
- if(opts.dryRun)return output
97
- const dependencies=manifest.dependencies||{}
98
- const deps=schema==='zod'?{'zod':'^4.1.0'}:schema==='typebox'?{'@feathersjs/typebox':'^5.0.50','ajv':'^8.17.1'}:{'ajv':'^8.17.1'}
99
- if(auth==='jwt')deps['@feathersjs/authentication']='^5.0.50'
100
- for(const [name,version] of Object.entries(deps))dependencies[name]??=version
101
- await mkdir(destination,{recursive:false})
102
- for(const [name,contents] of Object.entries({'schema.mjs':schemaSource(schema),'service.class.mjs':classSource(),[`${opts.name}.service.mjs`]:registrar(opts.name,auth)}))await writeFile(join(destination,name),contents,{flag:'wx'})
103
- await writeFile(serverPath,text.replace(marker,marker+moduleEntry))
104
- await writeFile(manifestPath,JSON.stringify({...manifest,dependencies},null,2)+'\n')
105
- return output
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.50','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.4",
3
+ "version": "0.1.0-rc.5",
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": "./bin/create-feathers-app.mjs"
8
+ "create-feathers-app": "bin/create-feathers-app.mjs"
9
9
  },
10
10
  "engines": {
11
11
  "node": ">=22.12.0"