Tighten response schemas: add required and additionalProperties: false
Generated frontend types (frontend/src/types/generated.ts) are all-optional because response schemas don't declare required or additionalProperties. This forces defensive ?. chains even for fields like id and produces [key: string]: unknown indexers on types whose views return a closed shape.
Scope
For each response schema across ~23 files (see below), two passes:
required — list fields the view unconditionally assigns. Conditional assignments (if (x) {...}, obj?.x) stay optional. For views with branching shapes (e.g. Device.statusOnly), require only the intersection or split the schema. nullable: true fields can still be required — "required" means "always present", not "non-null".
additionalProperties: false — add it wherever the view returns a closed shape. Keep true only where a schema genuinely passes through arbitrary data (audit log scope, body, trigger); call those out in the PR.
Files (79 schemas)
forge/db/views/: AccessToken (8), Application (5), AuditLog (5), BOM (2), BrokerCredentials (1), Device (5), DeviceGroup (3), Invitation (2), Notification (2), Project (7), ProjectSnapshot (5), ProjectStack (2), ProjectTemplate (2), ProjectType (2), Team (3), TeamType (3), User (5)
forge/ee/db/views/: FlowTemplate (4), MCPRegistrations (2), Pipeline (2), PipelineStage (2), Table (2)
forge/routes/api-docs.js: APIStatus, APIError, PaginationParams, PaginationMeta, LinksMeta (5)
Test plan
All tests passed
Notable changes beyond field lists
Most of the diff is mechanical (required arrays, additionalProperties: false). These are the judgment calls.
| Change |
Category |
Why |
Risk |
Snapshot.device view: Device → DeviceSummary |
Wire shape |
Full Device shape unnecessary on snapshot lists |
Frontend grep showed only id / name are read |
DeviceSummary.application: Application → ApplicationSummary |
Wire shape |
Narrower embedded object |
No frontend reads of team / createdAt / updatedAt on embedded device app |
applicationDeviceGroups PUT /settings: {} → { status: 'okay' } |
Wire shape |
APIStatus requires status |
Frontend ignores body |
Team GET /:teamId and /slug/:teamSlug: Team → anyOf: [Team, TeamSummary] |
Wire shape |
Handler already emitted either based on role |
Schema now matches runtime — no behavioral change |
Team-broker GET /:brokerId: MQTTBroker → anyOf: [MQTTBroker, { state }] |
Wire shape |
Suspended team-broker returns a { state: 'suspended' } stub |
Additive |
Invitation: removed allOf: [UserSummary] at root |
Wire shape |
View never spread UserSummary at root — schema was lying |
No wire change |
Snapshot views inline a narrow { id, username, name, avatar } user shape instead of $ref: 'UserSummary' |
Privacy |
Avoids leaking admin / suspended / createdAt on every snapshot list. Mirrors AuditLog.TimelineEntry |
Don't DRY this up to UserSummary — would re-introduce the leak |
flowBlueprints POST/PUT/import and 3rdPartyBroker POST inline body schemas |
Pattern |
Shared response $refs break as request bodies once required is added (server fields like id, createdAt aren't sent by clients) |
Body shape now duplicated — keep in sync with response on edits |
teamInvitations resend / pipeline stage PUT: re-hydrate via byId after reload() |
Pattern |
Sequelize reload() doesn't reload associations; view was reading stale invitor / Devices |
Adds one DB roundtrip per request |
Dev response-validator Ajv uses removeAdditional: false |
Pattern |
Avoids an Ajv quirk where allOf + additionalProperties: true strips fields during outer required evaluation |
Dev-only (NODE_ENV === 'development'); Fastify's request-body validator is a separate Ajv instance and is unaffected |
expert.js /mcp/features keeps additionalProperties: true |
Pattern |
fast-json-stringify strips undeclared fields at serialization, but Ajv response-validation runs before that — locking would reject data the wire never emits |
Comment in code; test relies on this |
Deferred
- PUT
/projects/:instanceId import/start early-return now hydrates a full Project view to satisfy the tightened response schema, adding a DB roundtrip on a "return fast, finish async" path. Follow-up: narrow the 200 schema for that branch so {} (or a thin { status: 'importing' }) validates if we want to.
Tighten response schemas: add
requiredandadditionalProperties: falseGenerated frontend types (
frontend/src/types/generated.ts) are all-optional because response schemas don't declarerequiredoradditionalProperties. This forces defensive?.chains even for fields likeidand produces[key: string]: unknownindexers on types whose views return a closed shape.Scope
For each response schema across ~23 files (see below), two passes:
required— list fields the view unconditionally assigns. Conditional assignments (if (x) {...},obj?.x) stay optional. For views with branching shapes (e.g.Device.statusOnly), require only the intersection or split the schema.nullable: truefields can still be required — "required" means "always present", not "non-null".additionalProperties: false— add it wherever the view returns a closed shape. Keeptrueonly where a schema genuinely passes through arbitrary data (audit logscope,body,trigger); call those out in the PR.Files (79 schemas)
forge/db/views/: AccessToken (8), Application (5), AuditLog (5), BOM (2), BrokerCredentials (1), Device (5), DeviceGroup (3), Invitation (2), Notification (2), Project (7), ProjectSnapshot (5), ProjectStack (2), ProjectTemplate (2), ProjectType (2), Team (3), TeamType (3), User (5)forge/ee/db/views/: FlowTemplate (4), MCPRegistrations (2), Pipeline (2), PipelineStage (2), Table (2)forge/routes/api-docs.js: APIStatus, APIError, PaginationParams, PaginationMeta, LinksMeta (5)Test plan
All tests passed
NODE_ENV=development npm run test:system— 61 passing, 0 failing, 0 validator errors.npm run test:unit:forge— run on CI (local env has pre-existing async_hooks crash, unrelated to this PR).npm run generate:types+npm run test:unit:frontend+npm run build— types regenerated (599-line diff — 315 insertions, 284 deletions), 429/429 frontend tests pass, build clean.Manual dev browser test — any 500 with
FST_RESPONSE_VALIDATION_FAILED_VALIDATIONis drift. A 200 is the pass signal.How to run: boot
NODE_ENV=development npm run serve, log into the UI in Chrome, DevTools → Network tab → save HAR aspackages/flowfuse/log.mdafter each set of clicks, paste the new-log marker in chat. Same workflow as PR 1.GET list + GET single for each (POST/PUT/DELETE optional — most of the schema surface is in reads). Tick when the endpoint returns 200 with no validator errors:
OS
GET /api/v1/templates— Admin → Templates listGET /api/v1/templates/:id— click into a specific templateGET /api/v1/user/invitations— user menu → InvitationsGET /api/v1/user/notifications— user menu → bell iconGET /api/v1/user/tokens— User settings → Access TokensGET /api/v1/teams/:teamId/audit-log(both simple + scope=team&includeChildren=true variants)GET /api/v1/projects/:id/audit-log— Instance → Audit Log tabGET /api/v1/applications/:id/audit-log?scope=application&includeChildren=true— fix confirmedGET /api/v1/admin/audit-log?scope=platform— Admin → Audit LogGET /api/v1/teams/:teamId/devices/provisioning— Team → Devices → Provisioning tabEE
GET /api/v1/applications/:id/pipelines— Application → DevOps PipelinesGET /api/v1/teams/:teamId/pipelines— team-level pipelines listGET /api/v1/flow-blueprints— New Instance → blueprint pickerGET /api/v1/flow-blueprints/:id— click a specific blueprintGET /api/v1/teams/:teamId/databases— Team → TablesGET /api/v1/teams/:teamId/databases/:id— via console fetchGET /api/v1/teams/:teamId/databases/:id/tables/:tableName— sameDatabaseTableschema as adjacent endpoints, drivers all return the declared 6-field shapeGET /api/v1/teams/:teamId/mcp— via console fetchMain entity smoke
GET /api/v1/teams/slug/:slug(team by slug)GET /api/v1/teams?limit=30&sort=createdAt-desc(admin team list — TeamType attrs fix)GET /api/v1/user/teams(UserTeamList — restructured during audit)GET /api/v1/teams/:teamId/membersGET /api/v1/teams/:teamId/applications?includeApplicationSummary=true...GET /api/v1/teams/:teamId/applications?excludeOwnerFiltering=true(Instances createdAt/updatedAt fix)GET /api/v1/teams/:teamId/devices(Team/Application include fix)GET /api/v1/teams/:teamId/devices?statusOnly=trueGET /api/v1/teams/:teamId/device-groups(Application include fix)GET /api/v1/teams/:teamId/bom(dependency.wanted optional)GET /api/v1/teams/:teamId/brokers/:id(MQTTBroker status/credentials fields added)GET /api/v1/teams/:teamId/brokers(broker list)GET /api/v1/teams/:teamId/brokers/:id/topicsGET /api/v1/teams/:teamId/broker/clientsGET /api/v1/teams/:teamId/invitationsGET /api/v1/teams/:teamId/instance-counts(various filters)GET /api/v1/teams/:teamId/projectsGET /api/v1/teams/:teamId/userGET /api/v1/applications/:idGET /api/v1/applications/:id/instancesGET /api/v1/applications/:id/devicesGET /api/v1/applications/:id/snapshots(Device.deviceSummary fix + query attrs)GET /api/v1/projects/:id(Instance — 12 projects hit, all 200)GET /api/v1/projects/:id/statusGET /api/v1/projects/:id/snapshots(Device.deviceSummary view swap + query attrs)GET /api/v1/projects/:id/logs(PaginationMeta reopened)GET /api/v1/projects/:id/devices/settings— fixed in PR 1, re-verifiedGET /api/v1/project-types(various filters),/team-types,/stacksGET /api/v1/settingsGET /api/v1/admin/license,/admin/statsGET /api/v1/users,/users?limit=30Notable changes beyond field lists
Most of the diff is mechanical (
requiredarrays,additionalProperties: false). These are the judgment calls.Snapshot.deviceview:Device→DeviceSummaryDeviceshape unnecessary on snapshot listsid/nameare readDeviceSummary.application:Application→ApplicationSummaryteam/createdAt/updatedAton embedded device appapplicationDeviceGroupsPUT/settings:{}→{ status: 'okay' }APIStatusrequiresstatus/:teamIdand/slug/:teamSlug:Team→anyOf: [Team, TeamSummary]/:brokerId:MQTTBroker→anyOf: [MQTTBroker, { state }]{ state: 'suspended' }stubInvitation: removedallOf: [UserSummary]at root{ id, username, name, avatar }user shape instead of$ref: 'UserSummary'admin/suspended/createdAton every snapshot list. MirrorsAuditLog.TimelineEntryUserSummary— would re-introduce the leakflowBlueprintsPOST/PUT/import and3rdPartyBrokerPOST inline body schemas$refs break as request bodies oncerequiredis added (server fields likeid,createdAtaren't sent by clients)teamInvitationsresend / pipeline stage PUT: re-hydrate viabyIdafterreload()reload()doesn't reload associations; view was reading staleinvitor/DevicesremoveAdditional: falseallOf+additionalProperties: truestrips fields during outerrequiredevaluationNODE_ENV === 'development'); Fastify's request-body validator is a separate Ajv instance and is unaffectedexpert.js /mcp/featureskeepsadditionalProperties: truefast-json-stringifystrips undeclared fields at serialization, but Ajv response-validation runs before that — locking would reject data the wire never emitsDeferred
/projects/:instanceIdimport/start early-return now hydrates a fullProjectview to satisfy the tightened response schema, adding a DB roundtrip on a "return fast, finish async" path. Follow-up: narrow the 200 schema for that branch so{}(or a thin{ status: 'importing' }) validates if we want to.