diff --git a/apps/api-server/package.json b/apps/api-server/package.json index 60ad1ebbb..cd87393d4 100644 --- a/apps/api-server/package.json +++ b/apps/api-server/package.json @@ -96,15 +96,17 @@ "pmx": "^1.6.7", "postcss": "^8.4.38", "postcss-prefix-selector": "^2.1.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", "redis": "^4.6.13", "sanitize-html": "^2.7.0", "sequelize": "^6.37.8", + "swagger-jsdoc": "^6.3.0", "umzug": "^3.2.1", - "react": "^19.2.4", - "react-dom": "^19.2.4", "ws": "^7.5.7" }, "devDependencies": { + "@apidevtools/swagger-parser": "^12.1.0", "@types/cron": "^2.4.0", "eslint": "^7.32.0", "eslint-config-airbnb": "^18.2.1", diff --git a/apps/api-server/src/lib/reporting/api-version.js b/apps/api-server/src/lib/reporting/api-version.js new file mode 100644 index 000000000..14a63052f --- /dev/null +++ b/apps/api-server/src/lib/reporting/api-version.js @@ -0,0 +1,13 @@ +'use strict'; + +/** + * The reporting API's major.minor.patch version (NLgov API Design Rules: + * every response must carry this in an API-Version header, not just the + * URI's /v1 segment). Shared between routes/api/reports/index.js (sets it on + * every response reached via that router) and api-token-scope-guard.js + * (globally mounted BEFORE that router, so its own 403s need the same header + * set independently — see Server.js's middleware order). + */ +const API_VERSION = '1.0.0'; + +module.exports = { API_VERSION }; diff --git a/apps/api-server/src/lib/reporting/filters.js b/apps/api-server/src/lib/reporting/filters.js index 1320801bb..9476564e1 100644 --- a/apps/api-server/src/lib/reporting/filters.js +++ b/apps/api-server/src/lib/reporting/filters.js @@ -6,6 +6,11 @@ const { Op } = require('sequelize'); // getProjectScope/getFieldTypes here does not load the database layer — unit // tests that inject opts.fieldTypes still never need a real DB connection. const { getProjectScope } = require('./component-registry'); +const { + fromFilterError, + fromFilterErrors, + sendProblem, +} = require('./problem-json'); // Query params the reporting endpoints understand. Everything else is ignored // (no error) — e.g. a stray projectId query param (projectId comes from the path). @@ -31,6 +36,20 @@ class ReportingFilterError extends Error { } } +/** + * Wraps 2+ independent ReportingFilterErrors found on the same request (e.g. + * an invalid dateFrom AND an unsupported status param). NLgov API Design + * Rules require every validation error to be reported together in one + * response, not one-at-a-time across repeated requests. + */ +class ReportingValidationErrors extends Error { + constructor(errors) { + super('Multiple validation errors'); + this.name = 'ReportingValidationErrors'; + this.errors = errors; + } +} + const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/; /** @@ -110,24 +129,43 @@ function buildReportingWhere(req, componentKey, opts = {}) { ); } - // Date range on createdAt — half-open [from, to) + // Collected across every independent check below so a single response can + // report ALL validation errors at once (NLgov API Design Rules), rather + // than the caller discovering them one request at a time. + const errors = []; + + // Date range on createdAt — half-open [from, to). dateFrom/dateTo are + // parsed independently so an invalid dateFrom doesn't hide an invalid + // dateTo; the ordering check only runs once both individually parsed OK. const createdAt = {}; + let dateFromValue; + let dateToValue; if (q.dateFrom !== undefined && q.dateFrom !== '') { - createdAt[Op.gte] = parseDateParam(q.dateFrom, 'dateFrom'); + try { + dateFromValue = parseDateParam(q.dateFrom, 'dateFrom'); + } catch (err) { + if (!(err instanceof ReportingFilterError)) throw err; + errors.push(err); + } } if (q.dateTo !== undefined && q.dateTo !== '') { - createdAt[Op.lt] = parseDateParam(q.dateTo, 'dateTo'); + try { + dateToValue = parseDateParam(q.dateTo, 'dateTo'); + } catch (err) { + if (!(err instanceof ReportingFilterError)) throw err; + errors.push(err); + } } - if ( - createdAt[Op.gte] && - createdAt[Op.lt] && - createdAt[Op.gte] >= createdAt[Op.lt] - ) { - throw new ReportingFilterError( - 'invalid_date_range', - 'dateFrom must be strictly before dateTo', - 'dateFrom', - 'The range is half-open [dateFrom, dateTo); ensure dateFrom < dateTo' + if (dateFromValue) createdAt[Op.gte] = dateFromValue; + if (dateToValue) createdAt[Op.lt] = dateToValue; + if (dateFromValue && dateToValue && dateFromValue >= dateToValue) { + errors.push( + new ReportingFilterError( + 'invalid_date_range', + 'dateFrom must be strictly before dateTo', + 'dateFrom', + 'The range is half-open [dateFrom, dateTo); ensure dateFrom < dateTo' + ) ); } if (Object.getOwnPropertySymbols(createdAt).length > 0) { @@ -140,33 +178,36 @@ function buildReportingWhere(req, componentKey, opts = {}) { opts.fieldTypes || require('./component-registry').getFieldTypes(componentKey); if (!Object.prototype.hasOwnProperty.call(fieldTypes, 'status')) { - throw new ReportingFilterError( - 'unsupported_status_filter', - `The '${componentKey}' report has no status field to filter on`, - 'status', - 'Remove the status parameter for this endpoint' + errors.push( + new ReportingFilterError( + 'unsupported_status_filter', + `The '${componentKey}' report has no status field to filter on`, + 'status', + 'Remove the status parameter for this endpoint' + ) ); + } else { + where.status = q.status; } - where.status = q.status; } + if (errors.length === 1) throw errors[0]; + if (errors.length > 1) throw new ReportingValidationErrors(errors); + return { where, include }; } /** - * Sends a ReportingFilterError as an HTTP 400 with the agreed body shape. - * Re-throws anything that is not a ReportingFilterError so the caller can 500. + * Sends a ReportingFilterError (or ReportingValidationErrors, for 2+ errors + * found at once) as an HTTP 400 in the RFC 9457 problem+json shape (NLgov API + * Design Rules). Re-throws anything else so the caller can 500. */ function respondFilterError(res, err) { + if (err instanceof ReportingValidationErrors) { + return sendProblem(res, 400, fromFilterErrors(err.errors)); + } if (!(err instanceof ReportingFilterError)) throw err; - return res.status(400).json({ - error: { - code: err.code, - message: err.message, - param: err.param, - hint: err.hint, - }, - }); + return sendProblem(res, 400, fromFilterError(err)); } module.exports = { @@ -174,5 +215,6 @@ module.exports = { respondFilterError, parseDateParam, ReportingFilterError, + ReportingValidationErrors, KNOWN_PARAMS, }; diff --git a/apps/api-server/src/lib/reporting/filters.test.js b/apps/api-server/src/lib/reporting/filters.test.js index 841042033..2f62d727f 100644 --- a/apps/api-server/src/lib/reporting/filters.test.js +++ b/apps/api-server/src/lib/reporting/filters.test.js @@ -5,6 +5,7 @@ const { buildReportingWhere, respondFilterError, ReportingFilterError, + ReportingValidationErrors, } = require('./filters'); const TYPES_WITH_STATUS = { status: 'ENUM', createdAt: 'DATE' }; @@ -112,16 +113,70 @@ describe('buildReportingWhere', () => { expect(err.code).toBe('unsupported_status_filter'); expect(err.param).toBe('status'); }); + + it('collects independent invalid dateFrom + dateTo into one ReportingValidationErrors (NLgov ADR: report all validation errors together)', () => { + let err; + try { + buildReportingWhere( + req({ dateFrom: 'nope', dateTo: 'also-nope' }), + 'resources', + { fieldTypes: TYPES_WITH_STATUS } + ); + } catch (e) { + err = e; + } + expect(err).toBeInstanceOf(ReportingValidationErrors); + expect(err.errors).toHaveLength(2); + expect(err.errors.map((e) => e.param)).toEqual(['dateFrom', 'dateTo']); + }); + + it('collects an invalid dateFrom together with an unsupported status filter', () => { + let err; + try { + buildReportingWhere(req({ dateFrom: 'nope', status: 'x' }), 'votes', { + fieldTypes: TYPES_NO_STATUS, + }); + } catch (e) { + err = e; + } + expect(err).toBeInstanceOf(ReportingValidationErrors); + expect(err.errors.map((e) => e.code)).toEqual([ + 'invalid_date', + 'unsupported_status_filter', + ]); + }); + + it('does not double-report an inverted range when one side is also individually invalid', () => { + // dateTo is unparseable — the range-order check must not ALSO fire, + // since it can't meaningfully compare an invalid value. + let err; + try { + buildReportingWhere( + req({ dateFrom: '2026-02-01', dateTo: 'nope' }), + 'resources', + { fieldTypes: TYPES_WITH_STATUS } + ); + } catch (e) { + err = e; + } + expect(err).toBeInstanceOf(ReportingFilterError); + expect(err.code).toBe('invalid_date'); + expect(err.param).toBe('dateTo'); + }); }); describe('respondFilterError', () => { - it('sends HTTP 400 with { error: { code, message, param, hint } }', () => { - const captured = {}; + it('sends HTTP 400 as an application/problem+json body (NLgov ADR)', () => { + const captured = { headers: {} }; const res = { status(c) { captured.status = c; return res; }, + set(k, v) { + captured.headers[k] = v; + return res; + }, json(b) { captured.body = b; return res; @@ -132,13 +187,57 @@ describe('respondFilterError', () => { new ReportingFilterError('invalid_date', 'bad', 'dateFrom', 'use ISO') ); expect(captured.status).toBe(400); + expect(captured.headers['Content-Type']).toBe('application/problem+json'); expect(captured.body).toEqual({ - error: { - code: 'invalid_date', - message: 'bad', - param: 'dateFrom', - hint: 'use ISO', + type: 'https://developer.overheid.nl/api-design-rules/problem/400', + title: 'bad', + status: 400, + detail: 'use ISO', + code: 'invalid_date', + param: 'dateFrom', + }); + }); + + it('sends multiple validation errors together as one problem+json body with an errors array', () => { + const captured = { headers: {} }; + const res = { + status(c) { + captured.status = c; + return res; + }, + set(k, v) { + captured.headers[k] = v; + return res; }, + json(b) { + captured.body = b; + return res; + }, + }; + respondFilterError( + res, + new ReportingValidationErrors([ + new ReportingFilterError( + 'invalid_date', + 'bad dateFrom', + 'dateFrom', + 'hint1' + ), + new ReportingFilterError( + 'invalid_date', + 'bad dateTo', + 'dateTo', + 'hint2' + ), + ]) + ); + expect(captured.status).toBe(400); + expect(captured.body.errors).toHaveLength(2); + expect(captured.body.errors[0]).toEqual({ + code: 'invalid_date', + title: 'bad dateFrom', + param: 'dateFrom', + detail: 'hint1', }); }); diff --git a/apps/api-server/src/lib/reporting/openapi/components.js b/apps/api-server/src/lib/reporting/openapi/components.js new file mode 100644 index 000000000..7a75efd8c --- /dev/null +++ b/apps/api-server/src/lib/reporting/openapi/components.js @@ -0,0 +1,135 @@ +'use strict'; + +/** + * Shared OpenAPI 3.x components for the reporting API (`@openapi` JSDoc blocks + * in routes/api/reports/*.js reference these via $ref, instead of repeating + * the same envelope/error/parameter shapes 12 times). + * + * swagger-jsdoc merges this `definition` object with the per-file `@openapi` + * annotations discovered via the `apis` glob (see ../../../routes/api/reports/openapi.js). + */ +const definition = { + openapi: '3.0.3', + info: { + title: 'Openstad reporting API', + version: '1.0.0', + description: + 'Read-only reporting endpoints for participation data, scoped per project and filtered by the enabled reporting data-scope.', + contact: { + name: 'Openstad', + url: 'https://openstad.org', + email: 'contact@openstad.org', + }, + }, + servers: [{ url: '/api/project/{projectId}/reports/v1' }], + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'osr_...', + description: + 'Reporting API token (osr_ prefix), scoped to a single project and to read-only reporting access.', + }, + }, + parameters: { + dateFrom: { + name: 'dateFrom', + in: 'query', + required: false, + schema: { type: 'string', format: 'date' }, + description: + "Half-open range start (inclusive) on createdAt, UTC. 'YYYY-MM-DD' or full ISO-8601.", + }, + dateTo: { + name: 'dateTo', + in: 'query', + required: false, + schema: { type: 'string', format: 'date' }, + description: + "Half-open range end (exclusive) on createdAt, UTC. 'YYYY-MM-DD' or full ISO-8601.", + }, + status: { + name: 'status', + in: 'query', + required: false, + schema: { type: 'string' }, + description: + 'Filters by status, only for components that have a status column.', + }, + page: { + name: 'page', + in: 'query', + required: false, + schema: { type: 'integer', minimum: 1, default: 1 }, + }, + pageSize: { + name: 'pageSize', + in: 'query', + required: false, + schema: { type: 'integer', minimum: 1, maximum: 1000, default: 100 }, + }, + }, + schemas: { + ReportEnvelope: { + type: 'object', + properties: { + data: { type: 'array', items: { type: 'object' } }, + nextLink: { + type: 'string', + nullable: true, + description: + 'Relative path to the next page, or null on the last page.', + }, + }, + }, + Problem: { + type: 'object', + description: 'RFC 9457 problem details (NLgov API Design Rules).', + properties: { + type: { type: 'string', format: 'uri' }, + title: { type: 'string' }, + status: { type: 'integer' }, + detail: { type: 'string' }, + errors: { + type: 'array', + description: + 'Present when multiple independent validation errors were found on the same request; each entry has the same shape as a single problem (code/title/param/detail).', + items: { type: 'object' }, + }, + }, + required: ['type', 'title', 'status'], + }, + }, + responses: { + BadRequest: { + description: 'Invalid filter parameter.', + content: { + 'application/problem+json': { + schema: { $ref: '#/components/schemas/Problem' }, + }, + }, + }, + Unauthorized: { + description: 'Missing or invalid reporting API token.', + content: { + 'application/problem+json': { + schema: { $ref: '#/components/schemas/Problem' }, + }, + }, + }, + Forbidden: { + description: + "Valid token, but this component/path is not enabled for the project's reporting scope.", + content: { + 'application/problem+json': { + schema: { $ref: '#/components/schemas/Problem' }, + }, + }, + }, + }, + }, + security: [{ bearerAuth: [] }], +}; + +module.exports = { definition }; diff --git a/apps/api-server/src/lib/reporting/paginate.test.js b/apps/api-server/src/lib/reporting/paginate.test.js index 95190e25f..7fb993744 100644 --- a/apps/api-server/src/lib/reporting/paginate.test.js +++ b/apps/api-server/src/lib/reporting/paginate.test.js @@ -22,7 +22,7 @@ const serialize = (_componentKey, row) => ({ id: row.id }); function makeReq(query = {}) { return { query, - baseUrl: '/api/project/1/reports', + baseUrl: '/api/project/1/reports/v1', path: '/resources', reportingScope: { componentKey: 'resources', enabledPersonalFields: [] }, }; @@ -67,7 +67,7 @@ describe('buildNextLink', () => { foo: 'bar', }); expect(buildNextLink(req, 2)).toBe( - 'api/project/1/reports/resources?dateFrom=2026-01-01&page=2&pageSize=100' + 'api/project/1/reports/v1/resources?dateFrom=2026-01-01&page=2&pageSize=100' ); }); }); @@ -86,7 +86,9 @@ describe('paginateReporting', () => { }); expect(data).toHaveLength(2); expect(data.map((d) => d.id)).toEqual([1, 2]); - expect(nextLink).toBe('api/project/1/reports/resources?page=2&pageSize=2'); + expect(nextLink).toBe( + 'api/project/1/reports/v1/resources?page=2&pageSize=2' + ); }); it('exactly pageSize rows → nextLink null (last page)', () => { diff --git a/apps/api-server/src/lib/reporting/problem-json.js b/apps/api-server/src/lib/reporting/problem-json.js new file mode 100644 index 000000000..16a8960e4 --- /dev/null +++ b/apps/api-server/src/lib/reporting/problem-json.js @@ -0,0 +1,171 @@ +'use strict'; + +/** + * problem-json — RFC 9457 (application/problem+json) error bodies for the + * reporting API (NLgov API Design Rules require this shape for every error + * response, not ad-hoc JSON). + * + * The reporting pipeline produces error bodies from four independent places, + * each with its own plain-JSON shape: + * - ReportingFilterError (400s, filters.js) + * - the reporting-token 401 (require-reporting-token.js) + * - the scope-guard 403s (api-token-scope-guard.js) + * - the app-wide error_handling.js 500 (mounted globally in Server.js, + * outside routes/api/reports/ — cannot be edited directly without + * affecting every non-reporting endpoint too) + * + * The first three call sendProblem()/buildProblem() directly. The fourth is + * caught by problemJsonWrapper, a res.json monkey-patch installed as the + * first router.use in routes/api/reports/index.js — the same idiom already + * used by report-finalize.js and report-field-filter.js. + */ + +const PROBLEM_TYPE_BASE = + 'https://developer.overheid.nl/api-design-rules/problem'; + +/** + * @param {number} status + * @param {{title: string, detail?: string, type?: string, [key: string]: any}} fields + * @returns {object} RFC 9457 problem body + */ +function buildProblem(status, { title, detail, type, ...extensions } = {}) { + return { + type: type || `${PROBLEM_TYPE_BASE}/${status}`, + title, + status, + ...(detail !== undefined ? { detail } : {}), + ...extensions, + }; +} + +/** + * Builds a problem body from a ReportingFilterError (filters.js) — reuses its + * existing code/message/param/hint fields rather than inventing new ones. + * @param {import('./filters').ReportingFilterError} err + */ +function fromFilterError(err) { + return buildProblem(400, { + title: err.message, + detail: err.hint, + code: err.code, + param: err.param, + }); +} + +/** + * Builds a problem body for multiple independent ReportingFilterErrors at + * once (filters.js's ReportingValidationErrors) — NLgov API Design Rules + * require every validation error to be reported together in a single + * response, not one-at-a-time across repeated requests. `errors` is a + * problem+json extension member (not part of RFC 9457 core, but a widely + * used convention for surfacing a list of sub-problems). + * @param {import('./filters').ReportingFilterError[]} errors + */ +function fromFilterErrors(errors) { + return buildProblem(400, { + title: 'Multiple validation errors', + errors: errors.map((err) => ({ + code: err.code, + title: err.message, + param: err.param, + detail: err.hint, + })), + }); +} + +/** + * Builds a problem body from a plain `{ error: string }` shape, as currently + * produced by require-reporting-token.js (401) and api-token-scope-guard.js + * (403). + * @param {number} status + * @param {string} message + */ +function fromPlainError(status, message) { + return buildProblem(status, { title: message }); +} + +/** + * Builds a problem body from the app-wide error_handling.js shape. + * + * `friendlyStatus` is frequently `undefined` in practice — error_handling.js + * derives it via `statuses[status]`, but the installed `statuses` package + * version exposes status text via `statuses.message[status]` instead, so + * `statuses[500]` etc. resolve to `undefined` (a pre-existing bug in + * error_handling.js, outside this reporting-only change's scope; confirmed + * live — every 500 in this repo currently has no friendlyStatus). Falls back + * to `message` as the title in that case, without duplicating it into detail. + * @param {{status: number, friendlyStatus?: string, message: string, errorStack: string}} body + */ +function fromAppError({ status, friendlyStatus, message, errorStack }) { + return buildProblem(status, { + title: friendlyStatus || message, + ...(friendlyStatus ? { detail: message } : {}), + ...(errorStack ? { errorStack } : {}), + }); +} + +/** + * Sends an already-built problem body on `res` with the correct status and + * content-type. + * @param {import('express').Response} res + * @param {number} status + * @param {object} body + */ +function sendProblem(res, status, body) { + res.status(status); + res.set('Content-Type', 'application/problem+json'); + return res.json(body); +} + +/** + * Narrow fingerprint for error_handling.js's response shape. Does NOT require + * `friendlyStatus` — confirmed live that it's routinely absent (see + * fromAppError's comment) since JSON.stringify drops an `undefined` value, so + * requiring it as a string would make this never match in practice. `status` + * + `message` + `errorStack` together (with `status` a number) is still a + * distinctive-enough marker: no 2xx reporting payload or problem+json body + * built by sendProblem ever carries an `errorStack` key. Anything that + * doesn't match is left untouched. + * @param {any} payload + */ +function isAppErrorShape(payload) { + return ( + payload && + typeof payload === 'object' && + !Array.isArray(payload) && + typeof payload.status === 'number' && + 'message' in payload && + 'errorStack' in payload + ); +} + +/** + * Express middleware: monkey-patches res.json so any response later sent by + * the app-wide error_handling.js handler (mounted outside routes/api/reports/, + * so it can't be edited directly here) is reshaped into problem+json too. + * Must be the first router.use in routes/api/reports/index.js so it wraps + * res.json before anything else in the chain. + */ +function problemJsonWrapper(req, res, next) { + const originalJson = res.json.bind(res); + + res.json = function problemJson(payload) { + if (res.statusCode >= 400 && isAppErrorShape(payload)) { + res.set('Content-Type', 'application/problem+json'); + return originalJson(fromAppError(payload)); + } + return originalJson(payload); + }; + + return next(); +} + +module.exports = { + buildProblem, + fromFilterError, + fromFilterErrors, + fromPlainError, + fromAppError, + sendProblem, + problemJsonWrapper, +}; diff --git a/apps/api-server/src/lib/reporting/problem-json.test.js b/apps/api-server/src/lib/reporting/problem-json.test.js new file mode 100644 index 000000000..3b8eb5985 --- /dev/null +++ b/apps/api-server/src/lib/reporting/problem-json.test.js @@ -0,0 +1,258 @@ +import { describe, expect, it, vi } from 'vitest'; + +const { + buildProblem, + fromFilterError, + fromFilterErrors, + fromPlainError, + fromAppError, + sendProblem, + problemJsonWrapper, +} = require('./problem-json'); +const { ReportingFilterError } = require('./filters'); + +function makeRes(initialStatus = 200) { + const res = { + statusCode: initialStatus, + _body: null, + _headers: {}, + set(key, value) { + this._headers[key] = value; + return this; + }, + status(code) { + this.statusCode = code; + return this; + }, + json(body) { + this._body = body; + return this; + }, + }; + return res; +} + +describe('buildProblem', () => { + it('builds an RFC 9457 body with a default type derived from status', () => { + const body = buildProblem(400, { title: 'Bad request' }); + expect(body).toEqual({ + type: 'https://developer.overheid.nl/api-design-rules/problem/400', + title: 'Bad request', + status: 400, + }); + }); + + it('includes detail only when provided', () => { + expect(buildProblem(404, { title: 'Not found' })).not.toHaveProperty( + 'detail' + ); + expect( + buildProblem(404, { title: 'Not found', detail: 'no such thing' }) + ).toHaveProperty('detail', 'no such thing'); + }); + + it('passes through extension fields', () => { + const body = buildProblem(400, { + title: 'x', + code: 'invalid_date', + param: 'dateFrom', + }); + expect(body.code).toBe('invalid_date'); + expect(body.param).toBe('dateFrom'); + }); +}); + +describe('fromFilterError', () => { + it('maps a ReportingFilterError onto the problem body, reusing its fields', () => { + const err = new ReportingFilterError( + 'invalid_date', + "'dateFrom' is not a valid date: 'nope'", + 'dateFrom', + 'Use YYYY-MM-DD or ISO-8601 UTC' + ); + const body = fromFilterError(err); + expect(body).toEqual({ + type: 'https://developer.overheid.nl/api-design-rules/problem/400', + title: "'dateFrom' is not a valid date: 'nope'", + status: 400, + detail: 'Use YYYY-MM-DD or ISO-8601 UTC', + code: 'invalid_date', + param: 'dateFrom', + }); + }); +}); + +describe('fromFilterErrors', () => { + it('maps multiple ReportingFilterErrors onto one problem body with an errors array', () => { + const errs = [ + new ReportingFilterError( + 'invalid_date', + "'dateFrom' is not a valid date: 'nope'", + 'dateFrom', + 'Use YYYY-MM-DD or ISO-8601 UTC' + ), + new ReportingFilterError( + 'unsupported_status_filter', + "The 'votes' report has no status field to filter on", + 'status', + 'Remove the status parameter for this endpoint' + ), + ]; + const body = fromFilterErrors(errs); + expect(body).toEqual({ + type: 'https://developer.overheid.nl/api-design-rules/problem/400', + title: 'Multiple validation errors', + status: 400, + errors: [ + { + code: 'invalid_date', + title: "'dateFrom' is not a valid date: 'nope'", + param: 'dateFrom', + detail: 'Use YYYY-MM-DD or ISO-8601 UTC', + }, + { + code: 'unsupported_status_filter', + title: "The 'votes' report has no status field to filter on", + param: 'status', + detail: 'Remove the status parameter for this endpoint', + }, + ], + }); + }); +}); + +describe('fromPlainError', () => { + it('maps a plain { error: string } shape (401/403) onto the problem body', () => { + expect( + fromPlainError(401, 'A valid reporting API token is required') + ).toEqual({ + type: 'https://developer.overheid.nl/api-design-rules/problem/401', + title: 'A valid reporting API token is required', + status: 401, + }); + }); +}); + +describe('fromAppError', () => { + it('maps the app-wide error_handling.js shape onto the problem body', () => { + const body = fromAppError({ + status: 500, + friendlyStatus: 'Internal Server Error', + message: 'Something broke', + errorStack: '', + }); + expect(body).toEqual({ + type: 'https://developer.overheid.nl/api-design-rules/problem/500', + title: 'Internal Server Error', + status: 500, + detail: 'Something broke', + }); + }); + + it('includes errorStack only when non-empty (dev/admin debug mode)', () => { + const body = fromAppError({ + status: 500, + friendlyStatus: 'Internal Server Error', + message: 'Something broke', + errorStack: 'Error: boom\n at x', + }); + expect(body.errorStack).toBe('Error: boom\n at x'); + }); + + it('falls back to message as the title when friendlyStatus is absent (confirmed live: statuses[status] resolves to undefined in this repo)', () => { + const body = fromAppError({ + status: 500, + message: 'Something broke', + errorStack: '', + }); + expect(body).toEqual({ + type: 'https://developer.overheid.nl/api-design-rules/problem/500', + title: 'Something broke', + status: 500, + }); + }); +}); + +describe('sendProblem', () => { + it('sets status, content-type, and sends the body', () => { + const res = makeRes(); + sendProblem(res, 400, { title: 'x', status: 400 }); + expect(res.statusCode).toBe(400); + expect(res._headers['Content-Type']).toBe('application/problem+json'); + expect(res._body).toEqual({ title: 'x', status: 400 }); + }); +}); + +describe('problemJsonWrapper', () => { + it('reshapes an error_handling.js-shaped 500 response into problem+json', () => { + const req = {}; + const res = makeRes(500); + const next = vi.fn(); + + problemJsonWrapper(req, res, next); + expect(next).toHaveBeenCalledOnce(); + + res.json({ + status: 500, + friendlyStatus: 'Internal Server Error', + message: 'Something broke', + errorStack: '', + }); + + expect(res._headers['Content-Type']).toBe('application/problem+json'); + expect(res._body).toEqual({ + type: 'https://developer.overheid.nl/api-design-rules/problem/500', + title: 'Internal Server Error', + status: 500, + detail: 'Something broke', + }); + }); + + it('reshapes the REAL runtime shape (no friendlyStatus — see fromAppError) into problem+json', () => { + const req = {}; + const res = makeRes(500); + const next = vi.fn(); + + problemJsonWrapper(req, res, next); + res.json({ + status: 500, + message: 'Something broke', + errorStack: '', + }); + + expect(res._headers['Content-Type']).toBe('application/problem+json'); + expect(res._body).toEqual({ + type: 'https://developer.overheid.nl/api-design-rules/problem/500', + title: 'Something broke', + status: 500, + }); + }); + + it('leaves an already-shaped problem+json body untouched (sent via sendProblem upstream)', () => { + const req = {}; + const res = makeRes(400); + const next = vi.fn(); + + problemJsonWrapper(req, res, next); + const problemBody = { + type: 'https://developer.overheid.nl/api-design-rules/problem/400', + title: 'x', + status: 400, + }; + res.json(problemBody); + + expect(res._body).toBe(problemBody); + }); + + it('leaves a normal 2xx payload untouched', () => { + const req = {}; + const res = makeRes(200); + const next = vi.fn(); + + problemJsonWrapper(req, res, next); + res.json({ data: [], nextLink: null }); + + expect(res._body).toEqual({ data: [], nextLink: null }); + expect(res._headers['Content-Type']).toBeUndefined(); + }); +}); diff --git a/apps/api-server/src/middleware/api-token-scope-guard.js b/apps/api-server/src/middleware/api-token-scope-guard.js index d184d0834..1c2f7e5e0 100644 --- a/apps/api-server/src/middleware/api-token-scope-guard.js +++ b/apps/api-server/src/middleware/api-token-scope-guard.js @@ -6,6 +6,11 @@ const { } = require('@openstad-headless/lib/report-data-scope'); const auditLogService = require('../services/audit-log'); const db = require('../db'); +const { + fromPlainError, + sendProblem, +} = require('../lib/reporting/problem-json'); +const { API_VERSION } = require('../lib/reporting/api-version'); // GET-routed paths that actually mutate state — must be blocked even for // reporting tokens that only send GET requests. @@ -116,11 +121,19 @@ function apiTokenScopeGuard(req, res, next) { return next(); } + // NLgov API Design Rules: every reporting response must carry API-Version, + // not just the URI's /v1 segment. This guard is mounted globally, BEFORE + // routes/api/reports/index.js (see Server.js), so a 403 sent from here + // would otherwise never reach that router's own API-Version middleware. + res.set('API-Version', API_VERSION); + // Reporting tokens are strictly read-only. if (req.method !== 'GET') { - return res - .status(403) - .json({ error: 'Reporting tokens only allow GET requests' }); + return sendProblem( + res, + 403, + fromPlainError(403, 'Reporting tokens only allow GET requests') + ); } // Block GET paths that mutate state (exact path-segment matching). @@ -130,9 +143,11 @@ function apiTokenScopeGuard(req, res, next) { if (idx !== -1) { const after = pathLower[idx + segment.length]; if (after === undefined || after === '/' || after === '?') { - return res - .status(403) - .json({ error: 'Path not allowed for reporting tokens' }); + return sendProblem( + res, + 403, + fromPlainError(403, 'Path not allowed for reporting tokens') + ); } } } @@ -151,9 +166,14 @@ function apiTokenScopeGuard(req, res, next) { const componentCfg = dataScope && dataScope[componentKey]; if (!componentCfg || !componentCfg.enabled) { - return res.status(403).json({ - error: `Component '${componentKey}' is not enabled for this project's reporting scope`, - }); + return sendProblem( + res, + 403, + fromPlainError( + 403, + `Component '${componentKey}' is not enabled for this project's reporting scope` + ) + ); } req.reportingScope = { @@ -173,9 +193,11 @@ function apiTokenScopeGuard(req, res, next) { if (!allowed) { logBlockedReportingPath(req).catch(() => {}); - return res - .status(403) - .json({ error: 'Path not allowed for reporting tokens' }); + return sendProblem( + res, + 403, + fromPlainError(403, 'Path not allowed for reporting tokens') + ); } if (USER_DATA_SEGMENTS.has(lastSegment)) { @@ -191,10 +213,14 @@ function apiTokenScopeGuard(req, res, next) { if (!usersEnabled) { logBlockedReportingPath(req).catch(() => {}); - return res.status(403).json({ - error: - "The 'users' reporting component is not enabled for this project's reporting scope", - }); + return sendProblem( + res, + 403, + fromPlainError( + 403, + "The 'users' reporting component is not enabled for this project's reporting scope" + ) + ); } req.reportingScope = { @@ -211,9 +237,14 @@ function apiTokenScopeGuard(req, res, next) { const enabledComponents = getEnabledComponents(req); if (enabledComponents.length === 0) { logBlockedReportingPath(req).catch(() => {}); - return res.status(403).json({ - error: 'No reporting components are enabled for this project', - }); + return sendProblem( + res, + 403, + fromPlainError( + 403, + 'No reporting components are enabled for this project' + ) + ); } req.reportingScope = { diff --git a/apps/api-server/src/middleware/api-token-scope-guard.test.js b/apps/api-server/src/middleware/api-token-scope-guard.test.js index d0c98d851..5b4cd982b 100644 --- a/apps/api-server/src/middleware/api-token-scope-guard.test.js +++ b/apps/api-server/src/middleware/api-token-scope-guard.test.js @@ -29,10 +29,15 @@ function makeRes() { const res = { _status: null, _body: null, + _headers: {}, status(code) { this._status = code; return this; }, + set(key, value) { + this._headers[key] = value; + return this; + }, json(body) { this._body = body; return this; @@ -79,6 +84,13 @@ describe('apiTokenScopeGuard', () => { expect(res._status).toBe(403); expect(next).not.toHaveBeenCalled(); + expect(res._headers['Content-Type']).toBe('application/problem+json'); + expect(res._headers['API-Version']).toBe('1.0.0'); + expect(res._body).toEqual({ + type: 'https://developer.overheid.nl/api-design-rules/problem/403', + title: 'Reporting tokens only allow GET requests', + status: 403, + }); }); it('blocks PUT with 403', () => { @@ -181,6 +193,23 @@ describe('apiTokenScopeGuard', () => { enabledPersonalFields: ['title'], }); }); + + it('sets API-Version even on the pass-through path (not just on 403s)', () => { + const req = makeReq({ + apiTokenScope: 'reports', + method: 'GET', + path: '/project/1/resource/total', + projectDataScope: { + resources: { enabled: true, personalFields: ['title'] }, + }, + }); + const res = makeRes(); + const next = vi.fn(); + + apiTokenScopeGuard(req, res, next); + + expect(res._headers['API-Version']).toBe('1.0.0'); + }); }); describe('reporting token — allowlisted non-component path', () => { diff --git a/apps/api-server/src/middleware/require-reporting-token.js b/apps/api-server/src/middleware/require-reporting-token.js index 7559a22f6..de983aad3 100644 --- a/apps/api-server/src/middleware/require-reporting-token.js +++ b/apps/api-server/src/middleware/require-reporting-token.js @@ -1,5 +1,10 @@ 'use strict'; +const { + fromPlainError, + sendProblem, +} = require('../lib/reporting/problem-json'); + /** * require-reporting-token — fail-closed authentication gate for the reporting * API (mounted at the top of routes/api/reports; covers /reports/*). @@ -25,9 +30,11 @@ function requireReportingToken(req, res, next) { if (req.apiTokenScope !== 'reports') { res.set('WWW-Authenticate', 'Bearer'); - return res - .status(401) - .json({ error: 'A valid reporting API token is required' }); + return sendProblem( + res, + 401, + fromPlainError(401, 'A valid reporting API token is required') + ); } return next(); } diff --git a/apps/api-server/src/middleware/require-reporting-token.test.js b/apps/api-server/src/middleware/require-reporting-token.test.js index 2a174e418..cf2095dae 100644 --- a/apps/api-server/src/middleware/require-reporting-token.test.js +++ b/apps/api-server/src/middleware/require-reporting-token.test.js @@ -33,8 +33,11 @@ describe('requireReportingToken', () => { expect(next).not.toHaveBeenCalled(); expect(res._status).toBe(401); + expect(res._headers['Content-Type']).toBe('application/problem+json'); expect(res._body).toEqual({ - error: 'A valid reporting API token is required', + type: 'https://developer.overheid.nl/api-design-rules/problem/401', + title: 'A valid reporting API token is required', + status: 401, }); expect(res._headers['WWW-Authenticate']).toBe('Bearer'); }); @@ -49,7 +52,9 @@ describe('requireReportingToken', () => { expect(next).not.toHaveBeenCalled(); expect(res._status).toBe(401); expect(res._body).toEqual({ - error: 'A valid reporting API token is required', + type: 'https://developer.overheid.nl/api-design-rules/problem/401', + title: 'A valid reporting API token is required', + status: 401, }); }); diff --git a/apps/api-server/src/routes/api/index.js b/apps/api-server/src/routes/api/index.js index abe974096..067aa521b 100755 --- a/apps/api-server/src/routes/api/index.js +++ b/apps/api-server/src/routes/api/index.js @@ -77,8 +77,10 @@ router.use('/project/:projectId(\\d+)/choicesguide', require('./choicesguide')); // actions router.use('/project/:projectId(\\d+)/action', require('./action')); -// reporting endpoints (Power BI) — path-scoped by projectId (issue #1651) -router.use('/project/:projectId(\\d+)/reports', require('./reports')); +// reporting endpoints (Power BI) — path-scoped by projectId (issue #1651). +// Versioned per NLgov API Design Rules (brain 444); no external consumers on +// an unversioned path existed yet, so this is a straight move, not a dual-mount. +router.use('/project/:projectId(\\d+)/reports/v1', require('./reports')); //widgets router.use('/project/:projectId(\\d+)/widgets', require('./widget')); diff --git a/apps/api-server/src/routes/api/reports/choice-guide-questions.js b/apps/api-server/src/routes/api/reports/choice-guide-questions.js index bc2e9afdf..9f8ddeae8 100644 --- a/apps/api-server/src/routes/api/reports/choice-guide-questions.js +++ b/apps/api-server/src/routes/api/reports/choice-guide-questions.js @@ -33,7 +33,29 @@ function buildQuestionRows(widgets) { return data; } -// GET /api/project/:projectId/reports/choice-guide-questions?widgetId= +/** + * @openapi + * /choice-guide-questions: + * get: + * summary: List choice-guide question rows, flattened from the guide's widget config + * tags: [Reports] + * security: [{ bearerAuth: [] }] + * parameters: + * - name: widgetId + * in: query + * required: false + * schema: { type: string } + * description: Restricts to one guide. Omit to flatten every choice-guide in the project. + * responses: + * 200: + * description: Question rows (not paginated — bounded by the number of questions per guide). + * content: + * application/json: + * schema: { $ref: '#/components/schemas/ReportEnvelope' } + * 401: { $ref: '#/components/responses/Unauthorized' } + * 403: { $ref: '#/components/responses/Forbidden' } + */ +// GET /api/project/:projectId/reports/v1/choice-guide-questions?widgetId= // // Flat guide -> question rows (#441). Not a real DB table — choice-guide // question definitions live in Widget.config.items (Widget.type='choiceguide'), diff --git a/apps/api-server/src/routes/api/reports/choice-guide-results.js b/apps/api-server/src/routes/api/reports/choice-guide-results.js index 1265f0fd1..95e6ba99c 100644 --- a/apps/api-server/src/routes/api/reports/choice-guide-results.js +++ b/apps/api-server/src/routes/api/reports/choice-guide-results.js @@ -32,7 +32,34 @@ function widgetItemsOf(plainRow) { return Array.isArray(items) ? items : []; } -// GET /api/project/:projectId/reports/choice-guide-results +/** + * @openapi + * /choice-guide-results: + * get: + * summary: List choice-guide results, with answers flattened per opted-in field + * tags: [Reports] + * security: [{ bearerAuth: [] }] + * parameters: + * - $ref: '#/components/parameters/dateFrom' + * - $ref: '#/components/parameters/dateTo' + * - $ref: '#/components/parameters/page' + * - $ref: '#/components/parameters/pageSize' + * - name: widgetId + * in: query + * required: false + * schema: { type: string } + * description: Restricts to results on one guide. Omit to union across every guide. + * responses: + * 200: + * description: A page of choice-guide results. + * content: + * application/json: + * schema: { $ref: '#/components/schemas/ReportEnvelope' } + * 400: { $ref: '#/components/responses/BadRequest' } + * 401: { $ref: '#/components/responses/Unauthorized' } + * 403: { $ref: '#/components/responses/Forbidden' } + */ +// GET /api/project/:projectId/reports/v1/choice-guide-results // // Reuses the existing `choiceguides` component (ChoicesGuideResult) — same // safeFields (id, projectId, widgetId, createdAt, updatedAt, isSpam) as the diff --git a/apps/api-server/src/routes/api/reports/choice-guides.js b/apps/api-server/src/routes/api/reports/choice-guides.js index 9eb4429fb..a673738ec 100644 --- a/apps/api-server/src/routes/api/reports/choice-guides.js +++ b/apps/api-server/src/routes/api/reports/choice-guides.js @@ -4,7 +4,29 @@ const { makeReportEndpoint, } = require('../../../lib/reporting/make-report-endpoint'); -// GET /api/project/:projectId/reports/choice-guides +/** + * @openapi + * /choice-guides: + * get: + * summary: List choice-guide definitions + * tags: [Reports] + * security: [{ bearerAuth: [] }] + * parameters: + * - $ref: '#/components/parameters/dateFrom' + * - $ref: '#/components/parameters/dateTo' + * - $ref: '#/components/parameters/page' + * - $ref: '#/components/parameters/pageSize' + * responses: + * 200: + * description: A page of choice-guide definitions. + * content: + * application/json: + * schema: { $ref: '#/components/schemas/ReportEnvelope' } + * 400: { $ref: '#/components/responses/BadRequest' } + * 401: { $ref: '#/components/responses/Unauthorized' } + * 403: { $ref: '#/components/responses/Forbidden' } + */ +// GET /api/project/:projectId/reports/v1/choice-guides // // Guide-definition metadata (#441). Backed by the Widget model // (type='choiceguide') rather than the ChoicesGuide table — see diff --git a/apps/api-server/src/routes/api/reports/comments.js b/apps/api-server/src/routes/api/reports/comments.js index cb8ee817e..c81a444a7 100644 --- a/apps/api-server/src/routes/api/reports/comments.js +++ b/apps/api-server/src/routes/api/reports/comments.js @@ -4,7 +4,29 @@ const { makeReportEndpoint, } = require('../../../lib/reporting/make-report-endpoint'); -// GET /api/project/:projectId/reports/comments +/** + * @openapi + * /comments: + * get: + * summary: List comments + * tags: [Reports] + * security: [{ bearerAuth: [] }] + * parameters: + * - $ref: '#/components/parameters/dateFrom' + * - $ref: '#/components/parameters/dateTo' + * - $ref: '#/components/parameters/page' + * - $ref: '#/components/parameters/pageSize' + * responses: + * 200: + * description: A page of comments. + * content: + * application/json: + * schema: { $ref: '#/components/schemas/ReportEnvelope' } + * 400: { $ref: '#/components/responses/BadRequest' } + * 401: { $ref: '#/components/responses/Unauthorized' } + * 403: { $ref: '#/components/responses/Forbidden' } + */ +// GET /api/project/:projectId/reports/v1/comments module.exports = makeReportEndpoint({ componentKey: 'comments', model: 'Comment', diff --git a/apps/api-server/src/routes/api/reports/enquiries.js b/apps/api-server/src/routes/api/reports/enquiries.js index 8ddf368c0..f47c6dc57 100644 --- a/apps/api-server/src/routes/api/reports/enquiries.js +++ b/apps/api-server/src/routes/api/reports/enquiries.js @@ -5,7 +5,30 @@ const { makeReportEndpoint, } = require('../../../lib/reporting/make-report-endpoint'); -// GET /api/project/:projectId/reports/enquiries +/** + * @openapi + * /enquiries: + * get: + * summary: List enquiry submissions (submissions on an enquete widget) + * tags: [Reports] + * security: [{ bearerAuth: [] }] + * parameters: + * - $ref: '#/components/parameters/dateFrom' + * - $ref: '#/components/parameters/dateTo' + * - $ref: '#/components/parameters/status' + * - $ref: '#/components/parameters/page' + * - $ref: '#/components/parameters/pageSize' + * responses: + * 200: + * description: A page of enquiry submissions. + * content: + * application/json: + * schema: { $ref: '#/components/schemas/ReportEnvelope' } + * 400: { $ref: '#/components/responses/BadRequest' } + * 401: { $ref: '#/components/responses/Unauthorized' } + * 403: { $ref: '#/components/responses/Forbidden' } + */ +// GET /api/project/:projectId/reports/v1/enquiries // ASSUMPTION (documented in the plan): "enquiries" == submissions whose Widget // has type 'enquete'. Reuses the `submissions` data-scope/fields; the path // segment `enquiries` is mapped to the `submissions` component in diff --git a/apps/api-server/src/routes/api/reports/index.js b/apps/api-server/src/routes/api/reports/index.js index b4c410ca0..57e9ba5b5 100644 --- a/apps/api-server/src/routes/api/reports/index.js +++ b/apps/api-server/src/routes/api/reports/index.js @@ -2,14 +2,58 @@ const express = require('express'); const requireReportingToken = require('../../../middleware/require-reporting-token'); +const { problemJsonWrapper } = require('../../../lib/reporting/problem-json'); +const { API_VERSION } = require('../../../lib/reporting/api-version'); -// Reporting router — mounted at /api/project/:projectId/reports (see +// Reporting router — mounted at /api/project/:projectId/reports/v1 (see // routes/api/index.js). mergeParams so req.params.projectId is available; the // project middleware sets req.project, and the (globally mounted) // api-token-scope-guard + report-field-filter + report-finalize handle // reporting-token access control, PII stripping and envelope finalization. const router = express.Router({ mergeParams: true }); +// NLgov API Design Rules: every error response must be application/problem+json +// (RFC 9457). Must be the FIRST router.use — it monkey-patches res.json so it +// also catches the app-wide error_handling.js 500 (mounted outside this router +// entirely, so it can't be edited directly here without affecting every +// non-reporting endpoint too). filters.js, require-reporting-token.js and +// api-token-scope-guard.js already send RFC 9457 bodies directly. +router.use(problemJsonWrapper); + +// NLgov API Design Rules: the major version must be reflected in a response +// header too, not just the URI. +router.use((req, res, next) => { + res.set('API-Version', API_VERSION); + next(); +}); + +// NLgov API Design Rules: publish OpenAPI 3.x at a fixed location. Mounted +// BEFORE the auth gate below — an integrator must be able to read the API +// shape before they can request a reporting token for it. (A request that +// DOES carry a valid reporting token still 403s here via the globally-mounted +// api-token-scope-guard, since /openapi.json matches no known reporting +// component — a narrow, documented edge case, not a security gap: the spec +// stays reachable for the unauthenticated case that matters.) +// +// Access-Control-Allow-Origin is hardcoded to '*' here specifically (not left +// to the app-wide, allowlist-based security-headers.js middleware) because +// the ADR requires the spec itself to be fetchable cross-origin by any tool — +// unlike every other reporting response, this one carries no per-project data +// to protect, so a wildcard is safe precisely because it's this narrow. Must +// also clear Access-Control-Allow-Credentials (set unconditionally to 'true' +// by the globally-mounted security-headers.js) — the Fetch/CORS spec forbids +// combining a wildcard origin with credentials:true, and browsers reject the +// response outright for any client that fetches with credentials included. +router.get( + '/openapi.json', + (req, res, next) => { + res.set('Access-Control-Allow-Origin', '*'); + res.removeHeader('Access-Control-Allow-Credentials'); + next(); + }, + require('./openapi') +); + // Fail-closed authentication gate: the globally-mounted reporting middleware // only CONSTRAINS a valid reporting token (they no-op when apiTokenScope is not // 'reports'), they never REQUIRE one. Without this gate the handlers below — diff --git a/apps/api-server/src/routes/api/reports/index.test.js b/apps/api-server/src/routes/api/reports/index.test.js new file mode 100644 index 000000000..bdf7cc248 --- /dev/null +++ b/apps/api-server/src/routes/api/reports/index.test.js @@ -0,0 +1,78 @@ +import express from 'express'; +import path from 'path'; +import request from 'supertest'; +import { describe, expect, it, vi } from 'vitest'; + +// Redirect the package import to the local worktree file so tests can run +// before npm install has updated the shared node_modules (same seam used by +// api-token-scope-guard.test.js). +vi.mock('@openstad-headless/lib/report-data-scope', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + return require( + path.resolve(__dirname, '../../../../../../packages/lib/report-data-scope') + ); +}); + +const reportsRouter = require('./index'); + +function makeApp() { + const app = express(); + // Mirrors security-headers.js, which is mounted globally in Server.js + // BEFORE this router and unconditionally sets this header on every + // response — needed to exercise the /openapi.json wildcard-CORS fix + // against the same conflict it has to clear in the real app. + app.use((req, res, next) => { + res.set('Access-Control-Allow-Credentials', 'true'); + next(); + }); + app.use('/api/project/:projectId/reports/v1', reportsRouter); + return app; +} + +describe('reports router — cross-cutting wiring', () => { + it('serves a valid OpenAPI spec at /openapi.json without a reporting token', async () => { + const res = await request(makeApp()).get( + '/api/project/1/reports/v1/openapi.json' + ); + expect(res.status).toBe(200); + expect(res.body.openapi).toBe('3.0.3'); + expect(Object.keys(res.body.paths)).toHaveLength(12); + }); + + it('sets the API-Version header on every response, including /openapi.json', async () => { + const res = await request(makeApp()).get( + '/api/project/1/reports/v1/openapi.json' + ); + expect(res.headers['api-version']).toBe('1.0.0'); + }); + + it('sets a wildcard Access-Control-Allow-Origin on /openapi.json regardless of environment (NLgov ADR: the spec must be fetchable cross-origin)', async () => { + const res = await request(makeApp()).get( + '/api/project/1/reports/v1/openapi.json' + ); + expect(res.headers['access-control-allow-origin']).toBe('*'); + }); + + it('clears Access-Control-Allow-Credentials on /openapi.json — the Fetch/CORS spec forbids combining it with a wildcard origin', async () => { + const res = await request(makeApp()).get( + '/api/project/1/reports/v1/openapi.json' + ); + expect(res.headers['access-control-allow-credentials']).toBeUndefined(); + }); + + it('401s a data endpoint with no reporting token, as application/problem+json', async () => { + const res = await request(makeApp()).get( + '/api/project/1/reports/v1/resources' + ); + expect(res.status).toBe(401); + expect(res.headers['content-type']).toContain('application/problem+json'); + expect(res.body).toEqual({ + type: 'https://developer.overheid.nl/api-design-rules/problem/401', + title: 'A valid reporting API token is required', + status: 401, + }); + // The API-Version header middleware runs before the auth gate, so it's + // still present even on this 401. + expect(res.headers['api-version']).toBe('1.0.0'); + }); +}); diff --git a/apps/api-server/src/routes/api/reports/openapi.js b/apps/api-server/src/routes/api/reports/openapi.js new file mode 100644 index 000000000..44867e01c --- /dev/null +++ b/apps/api-server/src/routes/api/reports/openapi.js @@ -0,0 +1,24 @@ +'use strict'; + +const path = require('path'); +const swaggerJsdoc = require('swagger-jsdoc'); +const { definition } = require('../../../lib/reporting/openapi/components'); + +// Built once at require-time: the spec describes the API's shape, not +// per-project data, so there is nothing to recompute per request. Scans every +// *.js file in this directory for @openapi JSDoc blocks (this file has none). +const spec = swaggerJsdoc({ + definition, + apis: [path.join(__dirname, '*.js')], +}); + +/** + * GET /openapi.json — deliberately mounted BEFORE requireReportingToken in + * reports/index.js: the spec is documentation, not reporting data, and an + * integrator needs to be able to read the API shape before they can request a + * reporting token for it. NLgov API Design Rules require this at a fixed, + * unauthenticated location. + */ +module.exports = function openapiSpec(req, res) { + res.json(spec); +}; diff --git a/apps/api-server/src/routes/api/reports/openapi.test.js b/apps/api-server/src/routes/api/reports/openapi.test.js new file mode 100644 index 000000000..ff3ae2d64 --- /dev/null +++ b/apps/api-server/src/routes/api/reports/openapi.test.js @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; + +const SwaggerParser = require('@apidevtools/swagger-parser'); +const openapiSpec = require('./openapi'); + +function getSpec() { + let captured; + openapiSpec({}, { json: (body) => (captured = body) }); + return captured; +} + +describe('GET /openapi.json', () => { + it('is a valid OpenAPI 3.x document', async () => { + const spec = getSpec(); + await expect( + SwaggerParser.validate(JSON.parse(JSON.stringify(spec))) + ).resolves.toBeDefined(); + }); + + it('documents all 12 reporting endpoints', () => { + const spec = getSpec(); + expect(Object.keys(spec.paths)).toHaveLength(12); + expect(spec.paths).toHaveProperty('/resources'); + expect(spec.paths).toHaveProperty('/submissions/fields'); + expect(spec.paths).toHaveProperty('/users/anonymized'); + expect(spec.paths).toHaveProperty('/users/aggregates'); + }); + + it('declares the bearer security scheme', () => { + const spec = getSpec(); + expect(spec.components.securitySchemes.bearerAuth).toMatchObject({ + type: 'http', + scheme: 'bearer', + }); + }); +}); diff --git a/apps/api-server/src/routes/api/reports/projects.js b/apps/api-server/src/routes/api/reports/projects.js index af3a857fd..c0cab47ae 100644 --- a/apps/api-server/src/routes/api/reports/projects.js +++ b/apps/api-server/src/routes/api/reports/projects.js @@ -4,7 +4,29 @@ const { makeReportEndpoint, } = require('../../../lib/reporting/make-report-endpoint'); -// GET /api/project/:projectId/reports/projects +/** + * @openapi + * /projects: + * get: + * summary: Get the token's own project + * tags: [Reports] + * security: [{ bearerAuth: [] }] + * parameters: + * - $ref: '#/components/parameters/dateFrom' + * - $ref: '#/components/parameters/dateTo' + * - $ref: '#/components/parameters/page' + * - $ref: '#/components/parameters/pageSize' + * responses: + * 200: + * description: The project row this token is scoped to. + * content: + * application/json: + * schema: { $ref: '#/components/schemas/ReportEnvelope' } + * 400: { $ref: '#/components/responses/BadRequest' } + * 401: { $ref: '#/components/responses/Unauthorized' } + * 403: { $ref: '#/components/responses/Forbidden' } + */ +// GET /api/project/:projectId/reports/v1/projects // Returns the token's own project row (path-scoped by id via buildReportingWhere). // includeUserId:false — projects have no participant user. module.exports = makeReportEndpoint({ diff --git a/apps/api-server/src/routes/api/reports/resources.js b/apps/api-server/src/routes/api/reports/resources.js index b6b3622bb..aa76bfbc5 100644 --- a/apps/api-server/src/routes/api/reports/resources.js +++ b/apps/api-server/src/routes/api/reports/resources.js @@ -4,7 +4,29 @@ const { makeReportEndpoint, } = require('../../../lib/reporting/make-report-endpoint'); -// GET /api/project/:projectId/reports/resources +/** + * @openapi + * /resources: + * get: + * summary: List resources + * tags: [Reports] + * security: [{ bearerAuth: [] }] + * parameters: + * - $ref: '#/components/parameters/dateFrom' + * - $ref: '#/components/parameters/dateTo' + * - $ref: '#/components/parameters/page' + * - $ref: '#/components/parameters/pageSize' + * responses: + * 200: + * description: A page of resources. + * content: + * application/json: + * schema: { $ref: '#/components/schemas/ReportEnvelope' } + * 400: { $ref: '#/components/responses/BadRequest' } + * 401: { $ref: '#/components/responses/Unauthorized' } + * 403: { $ref: '#/components/responses/Forbidden' } + */ +// GET /api/project/:projectId/reports/v1/resources module.exports = makeReportEndpoint({ componentKey: 'resources', model: 'Resource', diff --git a/apps/api-server/src/routes/api/reports/submissions-fields.js b/apps/api-server/src/routes/api/reports/submissions-fields.js index f0d182506..655577105 100644 --- a/apps/api-server/src/routes/api/reports/submissions-fields.js +++ b/apps/api-server/src/routes/api/reports/submissions-fields.js @@ -5,6 +5,10 @@ const { fieldKeyOf, CONTROL_FIELD_KEYS, } = require('../../../lib/reporting/flatten-submission'); +const { + buildProblem, + sendProblem, +} = require('../../../lib/reporting/problem-json'); // questionType values (packages/enquete/.../items.tsx hasOptions() switch) // that render as a choice list. @@ -60,7 +64,42 @@ function getEnabledFormFields(req) { return (submissionsScope && submissionsScope.formFields) || []; } -// GET /api/project/:projectId/reports/submissions/fields?widgetId= +/** + * @openapi + * /submissions/fields: + * get: + * summary: Describe the opted-in fields exposed by /submissions for one form + * tags: [Reports] + * security: [{ bearerAuth: [] }] + * parameters: + * - name: widgetId + * in: query + * required: true + * schema: { type: string } + * description: The form/widget to describe fields for. + * responses: + * 200: + * description: Form structure (name/label/type per field) — not participant data. + * content: + * application/json: + * schema: + * type: array + * items: + * type: object + * properties: + * name: { type: string } + * label: { type: string } + * type: { type: string, enum: [text, number, choice, date] } + * 400: { $ref: '#/components/responses/BadRequest' } + * 401: { $ref: '#/components/responses/Unauthorized' } + * 403: { $ref: '#/components/responses/Forbidden' } + * 404: + * description: Widget not found for this project. + * content: + * application/problem+json: + * schema: { $ref: '#/components/schemas/Problem' } + */ +// GET /api/project/:projectId/reports/v1/submissions/fields?widgetId= // // Metadata endpoint (#440 AC): describes the fields /reports/submissions // exposes for one form, including type. Treated as SCHEMA (form structure), @@ -76,13 +115,15 @@ module.exports = async function submissionsFields(req, res, next) { try { const widgetId = req.query.widgetId; if (!widgetId) { - return res.status(400).json({ - error: { + return sendProblem( + res, + 400, + buildProblem(400, { + title: '?widgetId= is required', code: 'missing_widget_id', - message: '?widgetId= is required', param: 'widgetId', - }, - }); + }) + ); } const widget = await db.Widget.findOne({ @@ -90,12 +131,14 @@ module.exports = async function submissionsFields(req, res, next) { attributes: ['id', 'config'], }); if (!widget) { - return res.status(404).json({ - error: { + return sendProblem( + res, + 404, + buildProblem(404, { + title: 'Widget not found for this project', code: 'widget_not_found', - message: 'Widget not found for this project', - }, - }); + }) + ); } const items = diff --git a/apps/api-server/src/routes/api/reports/submissions.js b/apps/api-server/src/routes/api/reports/submissions.js index 59d2012e7..cb9eb0850 100644 --- a/apps/api-server/src/routes/api/reports/submissions.js +++ b/apps/api-server/src/routes/api/reports/submissions.js @@ -32,7 +32,35 @@ function widgetItemsOf(plainRow) { return Array.isArray(items) ? items : []; } -// GET /api/project/:projectId/reports/submissions +/** + * @openapi + * /submissions: + * get: + * summary: List form submissions, with answer fields flattened per opted-in field + * tags: [Reports] + * security: [{ bearerAuth: [] }] + * parameters: + * - $ref: '#/components/parameters/dateFrom' + * - $ref: '#/components/parameters/dateTo' + * - $ref: '#/components/parameters/status' + * - $ref: '#/components/parameters/page' + * - $ref: '#/components/parameters/pageSize' + * - name: widgetId + * in: query + * required: false + * schema: { type: string } + * description: Restricts to submissions on one form/widget. Omit to union across every form. + * responses: + * 200: + * description: A page of submissions. + * content: + * application/json: + * schema: { $ref: '#/components/schemas/ReportEnvelope' } + * 400: { $ref: '#/components/responses/BadRequest' } + * 401: { $ref: '#/components/responses/Unauthorized' } + * 403: { $ref: '#/components/responses/Forbidden' } + */ +// GET /api/project/:projectId/reports/v1/submissions // // Optional ?widgetId= restricts to one form, giving a strict single-form // schema (recommended for Power BI). Without it, submissions across diff --git a/apps/api-server/src/routes/api/reports/users-aggregates.js b/apps/api-server/src/routes/api/reports/users-aggregates.js index a7d3374dc..93a204ced 100644 --- a/apps/api-server/src/routes/api/reports/users-aggregates.js +++ b/apps/api-server/src/routes/api/reports/users-aggregates.js @@ -48,7 +48,33 @@ async function countDistinct(query, bindvars) { return (rows && rows.counted) || 0; } -// GET /api/project/:projectId/reports/users/aggregates +/** + * @openapi + * /users/aggregates: + * get: + * summary: Get unique-participant counts across every data source + * tags: [Reports] + * security: [{ bearerAuth: [] }] + * responses: + * 200: + * description: Aggregate participant counts (not paginated). + * content: + * application/json: + * schema: + * type: object + * properties: + * uniqueParticipants: { type: integer } + * byType: + * type: array + * items: + * type: object + * properties: + * type: { type: string } + * count: { type: integer } + * 401: { $ref: '#/components/responses/Unauthorized' } + * 403: { $ref: '#/components/responses/Forbidden' } + */ +// GET /api/project/:projectId/reports/v1/users/aggregates // // Mandatory aggregation (#442 AC: "Not optional, this is a requirement") — // number of unique participants, overall and per data source. Non-component diff --git a/apps/api-server/src/routes/api/reports/users-anonymized.js b/apps/api-server/src/routes/api/reports/users-anonymized.js index eb9afc066..2ccf63c63 100644 --- a/apps/api-server/src/routes/api/reports/users-anonymized.js +++ b/apps/api-server/src/routes/api/reports/users-anonymized.js @@ -36,7 +36,26 @@ function toParticipantRow(row) { }; } -// GET /api/project/:projectId/reports/users/anonymized +/** + * @openapi + * /users/anonymized: + * get: + * summary: List pseudonymized participant rows across every data source + * tags: [Reports] + * security: [{ bearerAuth: [] }] + * parameters: + * - $ref: '#/components/parameters/page' + * - $ref: '#/components/parameters/pageSize' + * responses: + * 200: + * description: A page of pseudonymized participants (participantId, role, projectId, createdAt, lastLogin). No raw user id is ever exposed. + * content: + * application/json: + * schema: { $ref: '#/components/schemas/ReportEnvelope' } + * 401: { $ref: '#/components/responses/Unauthorized' } + * 403: { $ref: '#/components/responses/Forbidden' } + */ +// GET /api/project/:projectId/reports/v1/users/anonymized // // Pseudonymized participant rows — NOT a normal report-data-scope component // (see api-token-scope-guard.js ALLOWED_NON_COMPONENT_SEGMENTS): there is no diff --git a/apps/api-server/src/routes/api/reports/votes.js b/apps/api-server/src/routes/api/reports/votes.js index aab3855f3..54e056a53 100644 --- a/apps/api-server/src/routes/api/reports/votes.js +++ b/apps/api-server/src/routes/api/reports/votes.js @@ -4,7 +4,29 @@ const { makeReportEndpoint, } = require('../../../lib/reporting/make-report-endpoint'); -// GET /api/project/:projectId/reports/votes +/** + * @openapi + * /votes: + * get: + * summary: List votes + * tags: [Reports] + * security: [{ bearerAuth: [] }] + * parameters: + * - $ref: '#/components/parameters/dateFrom' + * - $ref: '#/components/parameters/dateTo' + * - $ref: '#/components/parameters/page' + * - $ref: '#/components/parameters/pageSize' + * responses: + * 200: + * description: A page of votes. + * content: + * application/json: + * schema: { $ref: '#/components/schemas/ReportEnvelope' } + * 400: { $ref: '#/components/responses/BadRequest' } + * 401: { $ref: '#/components/responses/Unauthorized' } + * 403: { $ref: '#/components/responses/Forbidden' } + */ +// GET /api/project/:projectId/reports/v1/votes // voteId is exposed as an explicit relationship id (== the vote's own id, issue // #1648); it is added AFTER the field filter via report-finalize (extraColumns). module.exports = makeReportEndpoint({ diff --git a/apps/mcp-server/README.md b/apps/mcp-server/README.md new file mode 100644 index 000000000..fdf29dffd --- /dev/null +++ b/apps/mcp-server/README.md @@ -0,0 +1,56 @@ +# Openstad reporting MCP server + +Exposes the Openstad reporting API (`/api/project/:projectId/reports/v1/...`) as a curated set of MCP tools, so an LLM client (e.g. Claude Desktop) can query resources, votes, comments, submissions, etc. without the model ever seeing a bearer token. + +## Multi-tenant design + +This is a single, shared, centrally-hosted server — not one process per municipality. The server holds **no reporting credentials of its own**. Every request to `/mcp` carries its own credentials: + +- `Authorization: Bearer ` — the same reporting API token used elsewhere against the Openstad API. +- `X-Reporting-Project-Id: ` — the id of the project that token was issued for. + +Both are required because a reporting token is an opaque string; the server has no database access and cannot look up which project a token belongs to on its own. The reporting API (`apps/api-server`) validates that the token actually matches the given project. + +If either header is missing or invalid, the affected tool call returns an MCP tool error (`isError: true`) rather than rejecting the whole connection — this keeps the failure visible in the LLM UI. + +## Server configuration + +Only deployment-level settings are configured via environment variables: + +| Variable | Default | Description | +| ---------------------------- | ------------------------ | ------------------------------------- | +| `MCP_REPORTING_API_BASE_URL` | `http://localhost:31410` | Base URL of the reporting API | +| `MCP_HOST` | `127.0.0.1` | Host to bind the `/mcp` HTTP endpoint | +| `MCP_PORT` | `3900` | Port to bind the `/mcp` HTTP endpoint | + +## Client (municipality) configuration + +Each municipality configures their own connection with their own reporting token and project id, using [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) as the stdio↔HTTP bridge. Example Claude Desktop config: + +```json +{ + "mcpServers": { + "openstad-reporting": { + "command": "npx", + "args": [ + "mcp-remote", + "https://reporting-mcp.example.org/mcp", + "--header", + "Authorization: Bearer ${REPORTING_TOKEN}", + "--header", + "X-Reporting-Project-Id: ${REPORTING_PROJECT_ID}" + ], + "env": { + "REPORTING_TOKEN": "osr_your_own_reporting_token", + "REPORTING_PROJECT_ID": "2" + } + } + } +} +``` + +## Development + +```bash +npm test --workspace=apps/mcp-server +``` diff --git a/apps/mcp-server/package.json b/apps/mcp-server/package.json new file mode 100644 index 000000000..67eb1eb9f --- /dev/null +++ b/apps/mcp-server/package.json @@ -0,0 +1,22 @@ +{ + "name": "@openstad-headless/mcp-server", + "version": "1.0.0", + "description": "MCP server exposing the Openstad reporting API as curated tools for LLM clients, without exposing the reporting bearer token to the model", + "main": "server.js", + "scripts": { + "start": "node server.js", + "test": "vitest run" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "dotenv": "^16.6.1", + "express": "^4.17.3", + "zod": "^3.22.4" + }, + "devDependencies": { + "supertest": "^4.0.2", + "vitest": "^3.2.3" + }, + "author": "Gemeente Amsterdam", + "license": "MIT" +} diff --git a/apps/mcp-server/server.js b/apps/mcp-server/server.js new file mode 100644 index 000000000..06b8a49bc --- /dev/null +++ b/apps/mcp-server/server.js @@ -0,0 +1,15 @@ +'use strict'; + +require('dotenv').config(); + +const { loadConfig } = require('./src/config'); +const { createApp } = require('./src/create-app'); + +const config = loadConfig(); +const app = createApp(config); + +app.listen(config.port, config.host, () => { + console.log( + `Openstad reporting MCP server listening on ${config.host}:${config.port}` + ); +}); diff --git a/apps/mcp-server/src/config.js b/apps/mcp-server/src/config.js new file mode 100644 index 000000000..a6bad8eec --- /dev/null +++ b/apps/mcp-server/src/config.js @@ -0,0 +1,30 @@ +'use strict'; + +/** + * Server-startup configuration for the reporting MCP server. The server + * itself holds no reporting credentials — it is a shared, multi-tenant + * process; each request carries its own reporting bearer token and project + * id (extracted per-request in create-app.js), so an LLM client can only + * ever ask for data that request's own token already scopes it to. + */ + +/** + * Parses a port number, treating 0 as a valid explicit value (the standard + * "let the OS assign a free port" convention) rather than falling back to the + * default — a plain `Number(x) || default` would incorrectly treat 0 as unset. + */ +function parsePort(value, fallback) { + if (value === undefined || value === '') return fallback; + const n = Number(value); + return Number.isFinite(n) && n >= 0 ? n : fallback; +} + +function loadConfig(env = process.env) { + return { + apiBaseUrl: env.MCP_REPORTING_API_BASE_URL || 'http://localhost:31410', + host: env.MCP_HOST || '127.0.0.1', + port: parsePort(env.MCP_PORT, 3900), + }; +} + +module.exports = { loadConfig }; diff --git a/apps/mcp-server/src/config.test.js b/apps/mcp-server/src/config.test.js new file mode 100644 index 000000000..4517b70b1 --- /dev/null +++ b/apps/mcp-server/src/config.test.js @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; + +const { loadConfig } = require('./config'); + +describe('loadConfig', () => { + it('applies defaults for apiBaseUrl, host and port', () => { + const config = loadConfig({}); + expect(config).toEqual({ + apiBaseUrl: 'http://localhost:31410', + host: '127.0.0.1', + port: 3900, + }); + }); + + it('honors explicit overrides', () => { + const config = loadConfig({ + MCP_REPORTING_API_BASE_URL: 'https://reports.example.org', + MCP_PORT: '4000', + }); + expect(config.apiBaseUrl).toBe('https://reports.example.org'); + expect(config.port).toBe(4000); + }); + + it('treats MCP_PORT=0 as an explicit value (OS-assigned free port), not "unset"', () => { + const config = loadConfig({ + MCP_PORT: '0', + }); + expect(config.port).toBe(0); + }); + + it('honors an explicit MCP_HOST override', () => { + const config = loadConfig({ + MCP_HOST: '0.0.0.0', + }); + expect(config.host).toBe('0.0.0.0'); + }); +}); diff --git a/apps/mcp-server/src/create-app.js b/apps/mcp-server/src/create-app.js new file mode 100644 index 000000000..cbcb08243 --- /dev/null +++ b/apps/mcp-server/src/create-app.js @@ -0,0 +1,89 @@ +'use strict'; + +const { + StreamableHTTPServerTransport, +} = require('@modelcontextprotocol/sdk/server/streamableHttp.js'); +const { + createMcpExpressApp, +} = require('@modelcontextprotocol/sdk/server/express.js'); +const { createServer } = require('./create-server'); + +/** + * Builds the Express app for the reporting MCP server. Separated from + * server.js's process bootstrap (dotenv, app.listen) so it can be exercised + * with supertest without opening a real network listener. + * + * This server is shared and multi-tenant: it holds no reporting credentials + * of its own. Every /mcp request carries its own reporting bearer token + * (Authorization header) and project id (X-Reporting-Project-Id header), + * which are extracted per request and used to build a request-scoped config + * — so concurrent requests for different municipalities never share state. + * @param {{apiBaseUrl: string, host: string, port: number}} config + */ +function createApp(config) { + const app = createMcpExpressApp({ host: config.host }); + + // Stateless mode: a fresh McpServer + transport per request, so one process + // can serve concurrent tool calls without shared session state — this + // server is a thin, stateless protocol adapter over the reporting API, + // nothing here needs to persist across requests. + app.post('/mcp', async (req, res) => { + let transport; + let server; + try { + const authHeader = req.get('Authorization') || ''; + const reportingToken = authHeader.startsWith('Bearer ') + ? authHeader.slice('Bearer '.length) + : undefined; + // Only accept a plain numeric project id — this value is interpolated + // into the outbound reporting API URL path (reporting-client.js's + // buildUrl), so anything else (e.g. `../..`) is treated as absent + // rather than passed through and risking a path-prefix rewrite. + const rawProjectId = req.get('X-Reporting-Project-Id') || ''; + const projectId = /^\d+$/.test(rawProjectId) ? rawProjectId : undefined; + + // Missing/invalid credentials are not rejected here — the tool layer + // (make-report-tool.js) returns an MCP tool error per call instead, so + // the failure surfaces in the LLM UI rather than as a bare HTTP error. + const requestConfig = { ...config, reportingToken, projectId }; + + server = createServer(requestConfig); + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + }); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + } catch (err) { + console.error('Error handling MCP request:', err); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { code: -32603, message: 'Internal server error' }, + id: null, + }); + } + } finally { + // Registered on every path (success, thrown error) so a request that + // throws inside handleRequest still cleans up the per-request + // McpServer/transport pair instead of leaking them. + res.on('close', () => { + transport?.close(); + server?.close(); + }); + } + }); + + app.get('/mcp', (req, res) => { + res.writeHead(405).end( + JSON.stringify({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Method not allowed.' }, + id: null, + }) + ); + }); + + return app; +} + +module.exports = { createApp }; diff --git a/apps/mcp-server/src/create-app.test.js b/apps/mcp-server/src/create-app.test.js new file mode 100644 index 000000000..b5b6a400b --- /dev/null +++ b/apps/mcp-server/src/create-app.test.js @@ -0,0 +1,123 @@ +import request from 'supertest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { createApp } = require('./create-app'); + +const BASE_CONFIG = { + apiBaseUrl: 'http://localhost:31410', + host: '127.0.0.1', + port: 0, +}; + +describe('createApp — per-request credential extraction', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('reads the reporting token from Authorization and the project id from X-Reporting-Project-Id', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: [], nextLink: null }), + }); + vi.stubGlobal('fetch', fetchMock); + const app = createApp(BASE_CONFIG); + + await request(app) + .post('/mcp') + .set('Authorization', 'Bearer osr_test') + .set('X-Reporting-Project-Id', '2') + .set('Accept', 'application/json, text/event-stream') + .send({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'reporting_resources', arguments: {} }, + }); + + expect(fetchMock).toHaveBeenCalled(); + const [url, options] = fetchMock.mock.calls[0]; + expect(url.pathname).toContain('/api/project/2/'); + expect(options.headers.Authorization).toBe('Bearer osr_test'); + }); + + it('does not leak a reporting token/project id between two concurrent tenants', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: [], nextLink: null }), + }); + vi.stubGlobal('fetch', fetchMock); + const app = createApp(BASE_CONFIG); + + const callTool = (token, projectId) => + request(app) + .post('/mcp') + .set('Authorization', `Bearer ${token}`) + .set('X-Reporting-Project-Id', projectId) + .set('Accept', 'application/json, text/event-stream') + .send({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'reporting_resources', arguments: {} }, + }); + + // Two "municipalities" hitting the same shared server instance at the + // same time — this is the scenario the request-scoped config exists to + // isolate, so the assertion below checks each request's own + // token/project pairing, not just that two calls happened. + await Promise.all([callTool('token-a', '2'), callTool('token-b', '9')]); + + expect(fetchMock).toHaveBeenCalledTimes(2); + for (const [url, options] of fetchMock.mock.calls) { + if (url.pathname.includes('/api/project/2/')) { + expect(options.headers.Authorization).toBe('Bearer token-a'); + } else if (url.pathname.includes('/api/project/9/')) { + expect(options.headers.Authorization).toBe('Bearer token-b'); + } else { + throw new Error(`unexpected project id in URL: ${url.pathname}`); + } + } + }); + + it('treats a non-numeric X-Reporting-Project-Id as absent instead of forwarding it to the reporting API', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: [], nextLink: null }), + }); + vi.stubGlobal('fetch', fetchMock); + const app = createApp(BASE_CONFIG); + + await request(app) + .post('/mcp') + .set('Authorization', 'Bearer osr_test') + .set('X-Reporting-Project-Id', '../../auth') + .set('Accept', 'application/json, text/event-stream') + .send({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'reporting_resources', arguments: {} }, + }); + + // A malformed project id must not reach buildUrl() at all — the tool + // layer's missing-projectId guard should short-circuit before fetch. + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('does not reject the connection when Authorization/X-Reporting-Project-Id are missing', async () => { + const app = createApp(BASE_CONFIG); + + const res = await request(app) + .post('/mcp') + .set('Accept', 'application/json, text/event-stream') + .send({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }); + + expect(res.status).not.toBe(401); + }); + + it('rejects GET /mcp with 405', async () => { + const app = createApp(BASE_CONFIG); + const res = await request(app).get('/mcp'); + expect(res.status).toBe(405); + }); +}); diff --git a/apps/mcp-server/src/create-server.js b/apps/mcp-server/src/create-server.js new file mode 100644 index 000000000..59d287ccb --- /dev/null +++ b/apps/mcp-server/src/create-server.js @@ -0,0 +1,29 @@ +'use strict'; + +const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js'); +const { TOOLS } = require('./tools/definitions'); + +/** + * Builds an McpServer with every reporting tool registered, bound to the + * given request-scoped config. The LLM client only ever sees tool names, + * descriptions and parameters — never `config.reportingToken`. + * @param {{apiBaseUrl: string, projectId?: string, reportingToken?: string}} config + */ +function createServer(config) { + const server = new McpServer({ + name: 'openstad-reporting-mcp-server', + version: '1.0.0', + }); + + for (const tool of TOOLS) { + server.registerTool( + tool.name, + { description: tool.description, inputSchema: tool.inputSchema }, + (args) => tool.handler(config, args) + ); + } + + return server; +} + +module.exports = { createServer }; diff --git a/apps/mcp-server/src/reporting-client.js b/apps/mcp-server/src/reporting-client.js new file mode 100644 index 000000000..e46d90de3 --- /dev/null +++ b/apps/mcp-server/src/reporting-client.js @@ -0,0 +1,84 @@ +'use strict'; + +/** + * Thin HTTP client for the reporting API. The MCP layer stays deliberately + * dumb: PII scoping and field-filtering already happen server-side in + * api-server (report-field-filter.js, api-token-scope-guard.js) — this client + * does not duplicate any of that, it only injects the bearer token and + * forwards query params. + */ + +/** + * @param {string} baseUrl + * @param {string} projectId + * @param {string} path - e.g. '/resources', leading slash required + * @param {Record} params + * @returns {URL} + */ +function buildUrl(baseUrl, projectId, path, params) { + const url = new URL(`/api/project/${projectId}/reports/v1${path}`, baseUrl); + for (const [key, value] of Object.entries(params || {})) { + if (value === undefined || value === null || value === '') continue; + url.searchParams.set(key, String(value)); + } + return url; +} + +/** + * Builds a human-readable message from an application/problem+json body (see + * apps/api-server's lib/reporting/problem-json.js), folding in `detail` and, + * for the multi-error case, every sub-error's title/param/detail. The MCP + * SDK's tool-error path only ever surfaces `Error.message` to the LLM (it + * does not read arbitrary properties off the thrown error) — so anything not + * folded into the message text here is invisible to the model. + * @param {object} problem + * @param {number} status + */ +function messageFromProblem(problem, status) { + if (!problem || typeof problem !== 'object') { + return `Reporting API request failed with status ${status}`; + } + if (Array.isArray(problem.errors) && problem.errors.length > 0) { + const parts = problem.errors.map((e) => + [e.param, [e.title, e.detail].filter(Boolean).join(' — ')] + .filter(Boolean) + .join(': ') + ); + return `${problem.title || 'Multiple validation errors'}: ${parts.join('; ')}`; + } + return ( + [problem.title, problem.detail].filter(Boolean).join(' — ') || + `Reporting API request failed with status ${status}` + ); +} + +/** + * Calls one reporting endpoint and returns its parsed JSON body. + * Throws on a non-2xx response, with a message built from the full + * application/problem+json body (see messageFromProblem) so the LLM sees the + * real, actionable reason — not just a generic title. `.status` and + * `.problem` are still attached for any caller that wants the raw body. + * + * @param {{apiBaseUrl: string, projectId: string, reportingToken: string}} config + * @param {string} path + * @param {Record} [params] + */ +async function fetchReportingData(config, path, params) { + const url = buildUrl(config.apiBaseUrl, config.projectId, path, params); + + const res = await fetch(url, { + headers: { Authorization: `Bearer ${config.reportingToken}` }, + }); + const body = await res.json(); + + if (!res.ok) { + const err = new Error(messageFromProblem(body, res.status)); + err.status = res.status; + err.problem = body; + throw err; + } + + return body; +} + +module.exports = { buildUrl, fetchReportingData }; diff --git a/apps/mcp-server/src/reporting-client.test.js b/apps/mcp-server/src/reporting-client.test.js new file mode 100644 index 000000000..f85f47661 --- /dev/null +++ b/apps/mcp-server/src/reporting-client.test.js @@ -0,0 +1,143 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { buildUrl, fetchReportingData } = require('./reporting-client'); + +describe('buildUrl', () => { + it('builds the versioned project-scoped path with query params, skipping empty/undefined values', () => { + const url = buildUrl('http://localhost:31410', '2', '/resources', { + dateFrom: '2026-01-01', + dateTo: '', + status: undefined, + pageSize: 20, + }); + expect(url.pathname).toBe('/api/project/2/reports/v1/resources'); + expect(url.searchParams.get('dateFrom')).toBe('2026-01-01'); + expect(url.searchParams.has('dateTo')).toBe(false); + expect(url.searchParams.has('status')).toBe(false); + expect(url.searchParams.get('pageSize')).toBe('20'); + }); +}); + +describe('fetchReportingData', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const config = { + apiBaseUrl: 'http://localhost:31410', + projectId: '2', + reportingToken: 'osr_test', + }; + + it('sends the bearer token and returns the parsed JSON body on success', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: [{ id: 1 }], nextLink: null }), + }); + vi.stubGlobal('fetch', fetchMock); + + const body = await fetchReportingData(config, '/resources', { + pageSize: 20, + }); + + expect(body).toEqual({ data: [{ id: 1 }], nextLink: null }); + const [url, options] = fetchMock.mock.calls[0]; + expect(String(url)).toBe( + 'http://localhost:31410/api/project/2/reports/v1/resources?pageSize=20' + ); + expect(options.headers.Authorization).toBe('Bearer osr_test'); + }); + + it('throws with the problem+json body attached on a non-2xx response', async () => { + const problem = { + type: 'https://developer.overheid.nl/api-design-rules/problem/403', + title: + "Component 'votes' is not enabled for this project's reporting scope", + status: 403, + }; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 403, + json: async () => problem, + }) + ); + + let err; + try { + await fetchReportingData(config, '/votes', {}); + } catch (e) { + err = e; + } + expect(err).toBeInstanceOf(Error); + expect(err.message).toBe(problem.title); + expect(err.status).toBe(403); + expect(err.problem).toEqual(problem); + }); + + it('folds a problem body detail into the error message (the SDK only ever surfaces .message to the LLM)', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 400, + json: async () => ({ + type: 'https://developer.overheid.nl/api-design-rules/problem/400', + title: "'dateFrom' is not a valid date: 'nope'", + detail: 'Use YYYY-MM-DD or ISO-8601 UTC', + status: 400, + }), + }) + ); + + let err; + try { + await fetchReportingData(config, '/resources', {}); + } catch (e) { + err = e; + } + expect(err.message).toBe( + "'dateFrom' is not a valid date: 'nope' — Use YYYY-MM-DD or ISO-8601 UTC" + ); + }); + + it('folds every sub-error of a multi-error problem body into one message', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 400, + json: async () => ({ + type: 'https://developer.overheid.nl/api-design-rules/problem/400', + title: 'Multiple validation errors', + status: 400, + errors: [ + { + code: 'invalid_date', + title: "'dateFrom' is not a valid date: 'nope'", + param: 'dateFrom', + detail: 'Use YYYY-MM-DD or ISO-8601 UTC', + }, + { + code: 'unsupported_status_filter', + title: "The 'votes' report has no status field to filter on", + param: 'status', + detail: 'Remove the status parameter for this endpoint', + }, + ], + }), + }) + ); + + let err; + try { + await fetchReportingData(config, '/votes', {}); + } catch (e) { + err = e; + } + expect(err.message).toBe( + "Multiple validation errors: dateFrom: 'dateFrom' is not a valid date: 'nope' — Use YYYY-MM-DD or ISO-8601 UTC; status: The 'votes' report has no status field to filter on — Remove the status parameter for this endpoint" + ); + }); +}); diff --git a/apps/mcp-server/src/tools/definitions.js b/apps/mcp-server/src/tools/definitions.js new file mode 100644 index 000000000..bcfdd11d2 --- /dev/null +++ b/apps/mcp-server/src/tools/definitions.js @@ -0,0 +1,103 @@ +'use strict'; + +const { makeReportTool } = require('./make-report-tool'); +const { + dateFrom, + dateTo, + status, + page, + pageSize, + widgetIdOptional, + widgetIdRequired, +} = require('./shared-params'); + +// Curated, one tool per reporting endpoint — NOT a generic "call any URL" +// tool. Best practice for OpenAPI-derived MCP tools is to keep the tool +// surface small and explicit rather than a 1:1 passthrough (see the MCP +// research memo in the reporting-api-standards plan): a generic tool would +// reintroduce the exact credential/scope risk this server exists to avoid. +const RECORD_PARAMS = { dateFrom, dateTo, page, pageSize }; +const RECORD_PARAMS_WITH_STATUS = { ...RECORD_PARAMS, status }; + +const TOOLS = [ + makeReportTool({ + name: 'reporting_resources', + description: 'List resources for this project.', + path: '/resources', + paramsShape: RECORD_PARAMS, + }), + makeReportTool({ + name: 'reporting_votes', + description: 'List votes for this project.', + path: '/votes', + paramsShape: RECORD_PARAMS, + }), + makeReportTool({ + name: 'reporting_comments', + description: 'List comments for this project.', + path: '/comments', + paramsShape: RECORD_PARAMS, + }), + makeReportTool({ + name: 'reporting_enquiries', + description: + 'List enquiry submissions (submissions on an enquete widget) for this project.', + path: '/enquiries', + paramsShape: RECORD_PARAMS_WITH_STATUS, + }), + makeReportTool({ + name: 'reporting_projects', + description: "Get this token's own project.", + path: '/projects', + paramsShape: RECORD_PARAMS, + }), + makeReportTool({ + name: 'reporting_submissions', + description: + 'List form submissions, with answer fields flattened per opted-in field.', + path: '/submissions', + paramsShape: { ...RECORD_PARAMS_WITH_STATUS, widgetId: widgetIdOptional }, + }), + makeReportTool({ + name: 'reporting_submissions_fields', + description: + 'Describe the opted-in fields exposed by reporting_submissions for one form.', + path: '/submissions/fields', + paramsShape: { widgetId: widgetIdRequired }, + }), + makeReportTool({ + name: 'reporting_choice_guides', + description: 'List choice-guide definitions for this project.', + path: '/choice-guides', + paramsShape: RECORD_PARAMS, + }), + makeReportTool({ + name: 'reporting_choice_guide_questions', + description: + 'List choice-guide question rows, flattened from the guide widget config.', + path: '/choice-guide-questions', + paramsShape: { widgetId: widgetIdOptional }, + }), + makeReportTool({ + name: 'reporting_choice_guide_results', + description: + 'List choice-guide results, with answers flattened per opted-in field.', + path: '/choice-guide-results', + paramsShape: { ...RECORD_PARAMS, widgetId: widgetIdOptional }, + }), + makeReportTool({ + name: 'reporting_users_anonymized', + description: + 'List pseudonymized participant rows across every data source.', + path: '/users/anonymized', + paramsShape: { page, pageSize }, + }), + makeReportTool({ + name: 'reporting_users_aggregates', + description: 'Get unique-participant counts across every data source.', + path: '/users/aggregates', + paramsShape: {}, + }), +]; + +module.exports = { TOOLS }; diff --git a/apps/mcp-server/src/tools/definitions.test.js b/apps/mcp-server/src/tools/definitions.test.js new file mode 100644 index 000000000..558d18fc9 --- /dev/null +++ b/apps/mcp-server/src/tools/definitions.test.js @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +const { TOOLS } = require('./definitions'); + +describe('reporting tool definitions', () => { + it('registers exactly one curated tool per reporting endpoint (12), no generic passthrough', () => { + expect(TOOLS).toHaveLength(12); + expect(TOOLS.map((t) => t.name)).toEqual([ + 'reporting_resources', + 'reporting_votes', + 'reporting_comments', + 'reporting_enquiries', + 'reporting_projects', + 'reporting_submissions', + 'reporting_submissions_fields', + 'reporting_choice_guides', + 'reporting_choice_guide_questions', + 'reporting_choice_guide_results', + 'reporting_users_anonymized', + 'reporting_users_aggregates', + ]); + }); + + it('every tool has a description and maps to a reporting API path', () => { + for (const tool of TOOLS) { + expect(tool.description).toEqual(expect.any(String)); + expect(tool.description.length).toBeGreaterThan(0); + expect(typeof tool.handler).toBe('function'); + } + }); + + it('requires widgetId for reporting_submissions_fields, but not for reporting_submissions', () => { + const fields = TOOLS.find((t) => t.name === 'reporting_submissions_fields'); + const submissions = TOOLS.find((t) => t.name === 'reporting_submissions'); + expect(fields.inputSchema.widgetId.isOptional()).toBe(false); + expect(submissions.inputSchema.widgetId.isOptional()).toBe(true); + }); + + it('only exposes a status filter on endpoints backed by a model with a status column', () => { + const withStatus = ['reporting_enquiries', 'reporting_submissions']; + for (const tool of TOOLS) { + const hasStatus = Object.prototype.hasOwnProperty.call( + tool.inputSchema, + 'status' + ); + expect(hasStatus).toBe(withStatus.includes(tool.name)); + } + }); +}); diff --git a/apps/mcp-server/src/tools/make-report-tool.js b/apps/mcp-server/src/tools/make-report-tool.js new file mode 100644 index 000000000..bb8f8a39f --- /dev/null +++ b/apps/mcp-server/src/tools/make-report-tool.js @@ -0,0 +1,62 @@ +'use strict'; + +const { fetchReportingData } = require('../reporting-client'); + +// Lower than the reporting API's own default of 100 (see paginate.js) — a +// tool result becomes part of the model's context, so a smaller default page +// keeps a single call from dumping an oversized blob into that context. +const DEFAULT_MCP_PAGE_SIZE = 20; + +/** + * Builds one MCP tool registration for a single reporting endpoint. Mirrors + * apps/api-server's make-report-endpoint.js factory pattern: one shared + * implementation, per-endpoint config, instead of 12 near-identical tool + * handlers. The SDK validates `args` against `inputSchema` before invoking + * this handler, so no manual validation is needed here. + * + * @param {{name: string, description: string, path: string, paramsShape?: import('zod').ZodRawShape}} cfg + * @returns {{name: string, description: string, inputSchema: object, handler: (config: object, args: object) => Promise}} + */ +function makeReportTool({ name, description, path, paramsShape = {} }) { + return { + name, + description, + inputSchema: paramsShape, + handler: async (config, args) => { + // This server is shared and multi-tenant: it holds no reporting + // credentials of its own, so a connection with a missing/invalid + // reporting token or project id (see create-app.js's per-request + // extraction) surfaces as a tool error here — visible in the LLM UI — + // rather than as a connection-wide HTTP rejection. + if (!config.reportingToken || !config.projectId) { + return { + content: [ + { + type: 'text', + text: 'Error: no reporting token / project id provided for this connection', + }, + ], + isError: true, + }; + } + + const params = { ...args }; + // Only default pageSize for tools that actually declare it — some + // endpoints (e.g. users/aggregates, submissions/fields) aren't + // paginated at all, and sending an unsolicited pageSize would be + // meaningless for them. + if ( + Object.prototype.hasOwnProperty.call(paramsShape, 'pageSize') && + params.pageSize === undefined + ) { + params.pageSize = DEFAULT_MCP_PAGE_SIZE; + } + const body = await fetchReportingData(config, path, params); + return { + content: [{ type: 'text', text: JSON.stringify(body) }], + }; + }, + }; +} + +module.exports = { makeReportTool, DEFAULT_MCP_PAGE_SIZE }; diff --git a/apps/mcp-server/src/tools/make-report-tool.test.js b/apps/mcp-server/src/tools/make-report-tool.test.js new file mode 100644 index 000000000..d2484eb0b --- /dev/null +++ b/apps/mcp-server/src/tools/make-report-tool.test.js @@ -0,0 +1,159 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { makeReportTool, DEFAULT_MCP_PAGE_SIZE } = require('./make-report-tool'); + +function stubFetch(implementation) { + vi.stubGlobal('fetch', vi.fn(implementation)); +} + +describe('makeReportTool', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const config = { + apiBaseUrl: 'http://localhost:31410', + projectId: '2', + reportingToken: 'osr_test', + }; + + it('injects the default MCP page size when the caller omits it, for a tool whose paramsShape declares pageSize', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: [], nextLink: null }), + }); + vi.stubGlobal('fetch', fetchMock); + + const tool = makeReportTool({ + name: 'reporting_resources', + description: 'x', + path: '/resources', + paramsShape: { pageSize: true }, + }); + await tool.handler(config, { dateFrom: '2026-01-01' }); + + const [url] = fetchMock.mock.calls[0]; + expect(String(url)).toContain('dateFrom=2026-01-01'); + expect(String(url)).toContain(`pageSize=${DEFAULT_MCP_PAGE_SIZE}`); + }); + + it('respects an explicit pageSize from the caller', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: [], nextLink: null }), + }); + vi.stubGlobal('fetch', fetchMock); + + const tool = makeReportTool({ + name: 'reporting_resources', + description: 'x', + path: '/resources', + paramsShape: { pageSize: true }, + }); + await tool.handler(config, { pageSize: 5 }); + + const [url] = fetchMock.mock.calls[0]; + expect(String(url)).toContain('pageSize=5'); + }); + + it('does NOT inject a pageSize for a tool whose paramsShape omits it (e.g. an unpaginated endpoint)', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ uniqueParticipants: 0, byType: [] }), + }); + vi.stubGlobal('fetch', fetchMock); + + const tool = makeReportTool({ + name: 'reporting_users_aggregates', + description: 'x', + path: '/users/aggregates', + // no paramsShape at all — mirrors definitions.js's reporting_users_aggregates + }); + await tool.handler(config, {}); + + const [url] = fetchMock.mock.calls[0]; + expect(String(url)).not.toContain('pageSize'); + }); + + it('returns the reporting API response as MCP text content', async () => { + stubFetch(async () => ({ + ok: true, + json: async () => ({ data: [{ id: 1 }], nextLink: null }), + })); + + const tool = makeReportTool({ + name: 'reporting_resources', + description: 'x', + path: '/resources', + }); + const result = await tool.handler(config, {}); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: JSON.stringify({ data: [{ id: 1 }], nextLink: null }), + }, + ], + }); + }); + + it('returns a tool error, without calling fetch, when reportingToken is missing', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const tool = makeReportTool({ + name: 'reporting_resources', + description: 'x', + path: '/resources', + }); + const result = await tool.handler( + { ...config, reportingToken: undefined }, + {} + ); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain( + 'no reporting token / project id provided' + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('returns a tool error, without calling fetch, when projectId is missing', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const tool = makeReportTool({ + name: 'reporting_resources', + description: 'x', + path: '/resources', + }); + const result = await tool.handler({ ...config, projectId: undefined }, {}); + + expect(result.isError).toBe(true); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('propagates the reporting API problem+json error so the SDK can surface it as a tool error', async () => { + stubFetch(async () => ({ + ok: false, + status: 403, + json: async () => ({ + type: 'https://developer.overheid.nl/api-design-rules/problem/403', + title: + "Component 'votes' is not enabled for this project's reporting scope", + status: 403, + }), + })); + + const tool = makeReportTool({ + name: 'reporting_votes', + description: 'x', + path: '/votes', + }); + + await expect(tool.handler(config, {})).rejects.toThrow( + "Component 'votes' is not enabled for this project's reporting scope" + ); + }); +}); diff --git a/apps/mcp-server/src/tools/shared-params.js b/apps/mcp-server/src/tools/shared-params.js new file mode 100644 index 000000000..627f15846 --- /dev/null +++ b/apps/mcp-server/src/tools/shared-params.js @@ -0,0 +1,68 @@ +'use strict'; + +const { z } = require('zod'); + +/** + * Shared zod parameter schemas, mirroring the reporting API's own shared + * OpenAPI parameters (apps/api-server/src/lib/reporting/openapi/components.js) + * so a tool's inputSchema documents the same constraints the API enforces. + */ + +const dateFrom = z + .string() + .optional() + .describe( + "Half-open range start (inclusive) on createdAt, UTC. 'YYYY-MM-DD' or full ISO-8601." + ); + +const dateTo = z + .string() + .optional() + .describe( + "Half-open range end (exclusive) on createdAt, UTC. 'YYYY-MM-DD' or full ISO-8601." + ); + +const status = z + .string() + .optional() + .describe( + 'Filters by status. Only supported by endpoints whose underlying data has a status field.' + ); + +const page = z + .number() + .int() + .min(1) + .optional() + .describe('1-based page number.'); + +// Deliberately lower than the reporting API's own default (100): bounds how +// much a single tool call can add to the model's context window. +const pageSize = z + .number() + .int() + .min(1) + .max(1000) + .optional() + .describe( + 'Rows per page. Defaults to 20 for this tool (lower than the raw API default of 100).' + ); + +const widgetIdOptional = z + .string() + .optional() + .describe('Restrict to one form/guide widget. Omit to include every widget.'); + +const widgetIdRequired = z + .string() + .describe('The form/widget to describe fields for.'); + +module.exports = { + dateFrom, + dateTo, + status, + page, + pageSize, + widgetIdOptional, + widgetIdRequired, +}; diff --git a/apps/mcp-server/vitest.config.ts b/apps/mcp-server/vitest.config.ts new file mode 100644 index 000000000..4ac6027d5 --- /dev/null +++ b/apps/mcp-server/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + }, +}); diff --git a/package-lock.json b/package-lock.json index cbe236051..0a4c3b8ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -519,10 +519,12 @@ "redis": "^4.6.13", "sanitize-html": "^2.7.0", "sequelize": "^6.37.8", + "swagger-jsdoc": "^6.3.0", "umzug": "^3.2.1", "ws": "^7.5.7" }, "devDependencies": { + "@apidevtools/swagger-parser": "^12.1.0", "@types/cron": "^2.4.0", "eslint": "^7.32.0", "eslint-config-airbnb": "^18.2.1", @@ -1305,6 +1307,32 @@ "url": "https://github.com/sindresorhus/file-type?sponsor=1" } }, + "apps/mcp-server": { + "name": "@openstad-headless/mcp-server", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "dotenv": "^16.6.1", + "express": "^4.17.3", + "zod": "^3.22.4" + }, + "devDependencies": { + "supertest": "^4.0.2" + } + }, + "apps/mcp-server/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -1317,6 +1345,54 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-14.0.1.tgz", + "integrity": "sha512-Oc96zvmxx1fqoSEdUmfmvvb59/KDOnUoJ7s2t7bISyAn0XEz57LCCw8k2Y4Pf3mwKaZLMciESALORLgfe2frCw==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, + "node_modules/@apidevtools/openapi-schemas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", + "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@apidevtools/swagger-methods": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", + "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==", + "license": "MIT" + }, + "node_modules/@apidevtools/swagger-parser": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-12.1.0.tgz", + "integrity": "sha512-e5mJoswsnAX0jG+J09xHFYQXb/bUc5S3pLpMxUuRUA2H8T2kni3yEoyz2R3Dltw5f4A6j6rPNMpWTK+iVDFlng==", + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "14.0.1", + "@apidevtools/openapi-schemas": "^2.1.0", + "@apidevtools/swagger-methods": "^3.0.2", + "ajv": "^8.17.1", + "ajv-draft-04": "^1.0.0", + "call-me-maybe": "^1.0.2" + }, + "peerDependencies": { + "openapi-types": ">=7" + } + }, "node_modules/@apostrophecms/anchors": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@apostrophecms/anchors/-/anchors-1.1.0.tgz", @@ -4765,6 +4841,18 @@ "@hapi/topo": "^5.0.0" } }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@hookform/resolvers": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz", @@ -6266,6 +6354,410 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/@mongodb-js/saslprep": { "version": "1.4.9", "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.9.tgz", @@ -6613,6 +7105,10 @@ "resolved": "packages/likes", "link": true }, + "node_modules/@openstad-headless/mcp-server": { + "resolved": "apps/mcp-server", + "link": true + }, "node_modules/@openstad-headless/multi-project-resource-overview": { "resolved": "packages/multi-project-resource-overview", "link": true @@ -17496,6 +17992,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/call-me-maybe": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", + "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", + "license": "MIT" + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -21453,6 +21955,27 @@ "bare-events": "^2.7.0" } }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/execa": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", @@ -23145,6 +23668,15 @@ "hermes-estree": "0.25.1" } }, + "node_modules/hono": { + "version": "4.12.28", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.28.tgz", + "integrity": "sha512-YwUvVpSF7m1yOblFPrU3Hbo8XhPheBoiyfGuII6z19LnOr6JpDnyyp7LFNrfV56wS8tpvtBFGRISHN02pDdLOA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/hosted-git-info": { "version": "2.8.9", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", @@ -23992,9 +24524,9 @@ "license": "MIT" }, "node_modules/ip-address": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.1.tgz", - "integrity": "sha512-1FMu8/N15Ck1BL551Jf42NYIoin2unWjLQ2Fze/DXryJRl5twqtwNHlO39qERGbIOcKYWHdgRryhOC+NG4eaLw==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", "engines": { "node": ">= 12" @@ -24447,6 +24979,12 @@ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "license": "MIT" }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-property": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", @@ -26187,6 +26725,12 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -27256,6 +27800,12 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "license": "MIT" }, + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", + "license": "MIT" + }, "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", @@ -30872,6 +31422,15 @@ "node": ">= 6" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkg-dir": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", @@ -33915,6 +34474,32 @@ "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", "license": "MIT" }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/rrweb-cssom": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", @@ -35912,6 +36497,153 @@ "node": ">=16" } }, + "node_modules/swagger-jsdoc": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.3.0.tgz", + "integrity": "sha512-I+iQjVGV3t28pOkQUJv2MncthvOtkEactOn8R76SvSYhxgtIn7FoqfDHwQaN+GBnQdXQLrhgDXseKitmJcHMsA==", + "license": "MIT", + "dependencies": { + "@apidevtools/swagger-parser": "^12.1.0", + "commander": "6.2.0", + "doctrine": "3.0.0", + "glob": "11.1.0", + "lodash.mergewith": "^4.6.2", + "yaml": "2.0.0-1" + }, + "bin": { + "swagger-jsdoc": "bin/swagger-jsdoc.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/swagger-jsdoc/node_modules/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/swagger-jsdoc/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/swagger-jsdoc/node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/swagger-jsdoc/node_modules/commander": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz", + "integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/swagger-jsdoc/node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "BlueOak-1.0.0", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/swagger-jsdoc/node_modules/jackspeak": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^9.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/swagger-jsdoc/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/swagger-jsdoc/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/swagger-jsdoc/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/swagger-jsdoc/node_modules/yaml": { + "version": "2.0.0-1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz", + "integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/swiper": { "version": "12.1.3", "resolved": "https://registry.npmjs.org/swiper/-/swiper-12.1.3.tgz", @@ -39435,6 +40167,15 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, "node_modules/zod-validation-error": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",