@msbci/form-server
v1.4.2
Published
Form server — REST API with Prisma, multi-tenant, multi-database
Readme
@msbci/form-server
REST API server for form management — Prisma ORM, multi-tenant, PostgreSQL + SQLite. Compatible with Express, Fastify, and Next.js API Routes.
Installation
npm install @msbci/form-server @msbci/form-core @prisma/client
npm install -D prismaUsage — Next.js API Routes
// app/api/forms/[...path]/route.ts
import { createFormRouter } from '@msbci/form-server'
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
const router = createFormRouter({
prisma,
auth: async (req) => {
const token = req.headers.authorization?.replace('Bearer ', '')
const user = await verifyToken(token)
return user ? { userId: user.id, tenantId: user.tenantId } : null
},
})
export async function GET(req: Request) {
const url = new URL(req.url)
const path = url.pathname.replace('/api/forms', '')
const result = await router.handle({
method: 'GET',
path,
params: {},
query: Object.fromEntries(url.searchParams),
body: null,
headers: Object.fromEntries(req.headers),
})
return Response.json(result.body, { status: result.status })
}
export async function POST(req: Request) {
const url = new URL(req.url)
const path = url.pathname.replace('/api/forms', '')
const body = await req.json()
const result = await router.handle({
method: 'POST',
path,
params: {},
query: Object.fromEntries(url.searchParams),
body,
headers: Object.fromEntries(req.headers),
})
return Response.json(result.body, { status: result.status })
}Usage — Express
import express from 'express'
import { createFormRouter } from '@msbci/form-server'
import { PrismaClient } from '@prisma/client'
const app = express()
const router = createFormRouter({ prisma: new PrismaClient() })
app.use('/api/forms', express.json(), async (req, res) => {
const result = await router.handle({
method: req.method,
path: req.path,
params: {},
query: req.query as Record<string, string>,
body: req.body,
headers: req.headers as Record<string, string>,
})
res.status(result.status).json(result.body)
})Auth Injectable
The package includes no auth logic. Inject your own via the auth option:
import type { AuthMiddleware } from '@msbci/form-server'
const myAuth: AuthMiddleware = async (req) => {
const token = req.headers.authorization?.replace('Bearer ', '')
if (!token) return null // → 401 Unauthorized
const user = await verifyJWT(token)
return { userId: user.id, tenantId: user.orgId }
}
createFormRouter({ prisma, auth: myAuth })Default: noAuth — allows all requests (for development).
Multi-Tenant
tenantId is an optional field on FormDefinition and FormSubmission:
- Pass
tenantIdwhen creating forms and submissions - Filter by
tenantIdin list queries - Auth middleware can set
tenantIdfrom the authenticated user
Database
| Provider | Use case | URL format |
|----------|----------|------------|
| PostgreSQL | Production | postgresql://user:pass@host:5432/db |
| SQLite | Development / Testing | file:./dev.db |
Copy the Prisma schema from the package and adjust the provider:
datasource db {
provider = "postgresql" // or "sqlite"
url = env("DATABASE_URL")
}Routes (25)
| Method | Path | Description |
|--------|------|-------------|
| GET | /form-types | List all form types |
| GET | /form-types/:id | Get form type |
| POST | /form-types | Create form type |
| PUT | /form-types/:id | Update form type |
| DELETE | /form-types/:id | Delete form type |
| GET | /forms | List forms (paginated, searchable) |
| GET | /forms/:id | Get form with full structure |
| POST | /forms | Create form |
| PUT | /forms/:id | Update form |
| DELETE | /forms/:id | Delete form (cascade) |
| GET | /forms/:id/export | Export form as JSON |
| POST | /forms/import | Import form from JSON |
| POST | /forms/:formId/pages | Create page |
| PUT | /pages/:id | Update page |
| DELETE | /pages/:id | Delete page |
| POST | /pages/:pageId/rosters | Create roster |
| PUT | /rosters/:id | Update roster |
| DELETE | /rosters/:id | Delete roster |
| POST | /pages/:pageId/variables | Create variable on page |
| POST | /rosters/:rosterId/variables | Create variable on roster |
| PUT | /variables/:id | Update variable |
| DELETE | /variables/:id | Delete variable |
| GET | /forms/:formId/submissions | List submissions (paginated) |
| GET | /submissions/:id | Get submission |
| POST | /submissions | Create submission |
| PUT | /submissions/:id | Update submission status |
All inputs validated with Zod. Errors returned as { status, message, data, errors }.
v1.4.2
- Import sans collision de code —
POST /forms/importderivait un code identique a la source ; dans un tenant donne, la contrainte@@unique([code, version, tenantId])etait violee (erreur 500 a la duplication d'un formulaire). L'import suffixe desormais le code (-COPY,-COPY-2, ...) lorsqu'il existe deja pour ce tenant. Sans tenant (NULL), le code est preserve (les NULL ne collisionnent pas en SQL) : comportement inchange. Test d'integration ajoute.
v1.4.1
- Documentation : retrait d'une référence à un hôte spécifique dans le changelog au profit d'une formulation générique (
l'application hôte). Aucun changement de code ni d'API.
v1.4.0
GET /forms/:idrenvoie la forme éditable — la route retournait l'objet Prisma brut (pivotscopes, JSON sérialisé) au lieu du type domaineIFormDefinition. Conséquence :form.scopeIdsétait absent et les scopes liés disparaissaient au rechargement dans l'éditeur. La route renvoie désormaisgetEditableFormDefinition(=dbToFormDefinitionsans résolution de templates) :scopeIdsprésents, JSON parsé, ettemplateId/templateOverridesconservés pour permettre l'édition (contrairement à/forms/:id/resolvedqui fusionne les templates). Test d'intégration ajouté.- Aucun changement de schéma. Bump mineur car la forme de réponse de
GET /forms/:idchange (alignée sur le type documenté).
v1.3.3 – v1.3.5
- Persistance profonde des pages —
updateFormDefinition(PUT du formulaire complet par l'éditeur visuel) fait un deep-upsert des pages, variables et rosters viapersistPages, partagé avecimportForm/createFormDefinition. Les pages ne se perdaient plus entre deux enregistrements. - Champs de mise en page —
FormVariable.startWithNewLine Boolean?etcolSpan Int?(colonnes nullables, additives) persistés et relus (extractVariableData/dbToVariable). - Écritures multi-tenant sûres — le
tenantIddes formulaires (et, côté application hôte, des scopes/templates) est dérivé du contexte de la requête plutôt que de la confiance au client. - Tolérance aux champs optionnels à
nullcôté hôte et republication corrigeant le protocoleworkspace:*dans le tarball publié (installation autonome viapnpm publish).
v1.3.2
- DataSourceConfig CRUD — nouvelle table
data_source_configs(@@unique([code, scopeId]),onDelete: Cascadedepuis Scope). 6 nouveaux endpoints REST :GET / POST /scopes/:scopeId/datasourcesGET / PUT / DELETE /datasources/:idGET /forms/:formId/datasources— endpoint agrégé : renvoie{ items: IDataSourceConfig[], scopeHeaders: IScopeHeaders[] }(configs + headers décryptés des scopes du form, tous en un seul appel).
- Chiffrement AES-256-GCM des headers admin via
src/utils/encryption.ts. Formatbase64(iv):base64(authTag):base64(ciphertext), IV 12 bytes (recommandé GCM), auth tag 16 bytes.MOSOBI_ENCRYPTION_KEY(32 bytes en base64 ou 64 chars hex) est requis pour écrire des headers :requireEncryption()lève503 Service Unavailablesinon.- Lecture tolérante : si la clé manque,
decrypt()renvoie{}avec un warning console — la lecture n'est jamais bloquée (la form reste rendable).
- Headers de Scope :
Scope.headers String?(chiffré JSON). Surfacé décrypté dansIScope.headerssur GET. Mergés avecIDataSourceConfig.headersOverridecôté renderer (config gagne). - Suppression d'un scope refusée en 409 si au moins une
DataSourceConfigle référence (en plus des forms et templates existants). - Nouveau helper
ApiError.serviceUnavailable(message)pour les 503. - Migration Postgres
20260524000000_datasource_configfournie pourapps/demo. - 9 nouveaux tests d'intégration (54 total) : CRUD complet, dédup code, agrégation par formId, refus 503 sans clé, chiffrement effectif en base (le bearer n'apparaît jamais en clair), suppression scope bloquée par data source.
- Tests serveur passent en
fileParallelism: falsepour éviter les courses sur la mêmetest.db.
v1.3.1
- Multi-scopes par formulaire — la colonne
FormDefinition.scopeIdest remplacée par une table pivotFormDefinitionScope(formId,scopeId,order) aveconDelete: Cascadede chaque côté. L'ordre dans le pivot porte la priorité des scopes. POST /formsetPUT /forms/:idacceptent désormaisscopeIds: string[](validé par Zod). Le payloadscopeIdn'est plus reconnu.GET /forms?scopeId=Xfiltre viascopes: { some: { scopeId } }(transparent pour le client).GET /forms/:idetGET /forms/:id/resolvedretournentscopeIdstriés parorderasc.dbToFormDefinitionextraitscopeIdsdepuis la relationscopesordonnée.- Migration Postgres
20260523000000_scope_pivotfournie pourapps/demo(drop column → create pivot + indexes + FKs). Côté serveur (SQLite test) :prisma db pushsynchronise le schéma au démarrage des tests. - Suppression d'un scope refusée en 409 si au moins une ligne pivot le référence (utilise
formDefinitionScope.count). - 2 nouveaux tests d'intégration (45 total) : préservation de l'ordre des scopes sur
GET /forms/:id/resolved, remplacement complet de la liste surPUT /forms/:id.
v1.3.0
- Scope : CRUD complet (
/scopes) + multi-tenant viatenantId @default("default") - VariableTemplate : CRUD complet (
/scopes/:scopeId/templates,/templates/:id) —codeetnameimmutables (enforced Zod.strict()+ service) GET /forms/:id/resolved— formulaire résolu via Option B (templates appliqués in-memory)- 10 nouveaux endpoints REST (
/scopes,/scopes/:id,/scopes/:scopeId/templates,/templates/:id,/forms/:id/resolved) - Suppression scope refusée en 409 si forms OU templates rattachés
- Tenant
defaultcréé automatiquement au boot viainitializeDefaults(idempotent,WeakSet<PrismaClient>) FormDefinition.scopeId+FormVariable.templateId/templateOverrides(champs Prisma)- 10 nouveaux tests d'intégration (43 total)
v1.2.0
LocalizedStringsérialisé en JSON côté persistence- Compatible avec les schémas multilingues
@msbci/form-corev1.2.0 - No breaking changes
What's new in v1.1.0
- Version aligned with
@msbci/form-corev1.1.0. The server transparently persists the extendedIFieldResponseMetadata(8 new optional fields includingdisplayValue,variableLabel, page / roster context) when host applications forward responses fromFormRenderer— no API or schema change required. - No breaking change. Drop-in upgrade from v1.0.x.
License
Copyright (c) 2026 MOSOBI — All rights reserved. Commercial license required. Contact: [email protected]
