diff --git a/digital-form-builder b/digital-form-builder index 253a393d..95b1d6da 160000 --- a/digital-form-builder +++ b/digital-form-builder @@ -1 +1 @@ -Subproject commit 253a393d96d8e46412989d2dcd95d9531a6970f8 +Subproject commit 95b1d6da06a8b98b097b84fdaa0748440aae9892 diff --git a/runner/config/custom-environment-variables.json b/runner/config/custom-environment-variables.json index 041d478b..5205008f 100644 --- a/runner/config/custom-environment-variables.json +++ b/runner/config/custom-environment-variables.json @@ -70,5 +70,7 @@ "sentryDsn": "SENTRY_DSN", "sentryTracesSampleRate": "SENTRY_TRACES_SAMPLE_RATE", "copilotEnv": "COPILOT_ENV", - "enableVirusScan": "ENABLE_VIRUS_SCAN" + "enableVirusScan": "ENABLE_VIRUS_SCAN", + "preAwardApiUrl": "PRE_AWARD_API_URL", + "apiHost": "API_HOST" } diff --git a/runner/config/default.js b/runner/config/default.js index 4049a989..93370eaf 100644 --- a/runner/config/default.js +++ b/runner/config/default.js @@ -115,4 +115,6 @@ module.exports = { copilotEnv: "", enableVirusScan: false, + preAwardApiUrl: "https://api.communities.gov.localhost:4004/forms", + apiHost: "api.communities.gov.localhost:4004", }; diff --git a/runner/src/server/index.ts b/runner/src/server/index.ts index 8656777e..3cee87e6 100644 --- a/runner/src/server/index.ts +++ b/runner/src/server/index.ts @@ -1,15 +1,11 @@ -// @ts-ignore import fs from "fs"; import "../instrument"; -// @ts-ignore import hapi, {ServerOptions} from "@hapi/hapi"; - import Scooter from "@hapi/scooter"; import inert from "@hapi/inert"; import Schmervice from "schmervice"; import blipp from "blipp"; -import {ConfigureFormsPlugin} from "./plugins/ConfigureFormsPlugin"; import {configureRateLimitPlugin} from "../../../digital-form-builder/runner/src/server/plugins/rateLimit"; import {configureBlankiePlugin} from "../../../digital-form-builder/runner/src/server/plugins/blankie"; import {configureCrumbPlugin} from "../../../digital-form-builder/runner/src/server/plugins/crumb"; @@ -27,6 +23,7 @@ import {HapiRequest, HapiResponseToolkit, RouteConfig} from "./types"; import getRequestInfo from "../../../digital-form-builder/runner/src/server/utils/getRequestInfo"; import {ViewLoaderPlugin} from "./plugins/ViewLoaderPlugin"; import publicRouterPlugin from "./plugins/engine/PublicRouterPlugin"; +import { FormRoutesPlugin } from "./plugins/engine/FormRoutesPlugin"; import {config} from "./plugins/utils/AdapterConfigurationSchema"; import errorHandlerPlugin from "./plugins/ErrorHandlerPlugin"; import {AdapterCacheService} from "./services"; @@ -39,6 +36,7 @@ import {catboxProvider} from "./services/AdapterCacheService"; import LanguagePlugin from "./plugins/LanguagePlugin"; import {TranslationLoaderService} from "./services/TranslationLoaderService"; import {WebhookService} from "./services/WebhookService"; +import {PreAwardApiService} from "./services/PreAwardApiService"; import {pluginLog} from "./plugins/logging"; const Sentry = require('@sentry/node'); @@ -69,7 +67,7 @@ const serverOptions = async (): Promise => { xframe: true, }, }, - cache: [{provider: catboxProvider()}], + cache: [{provider: catboxProvider()}], // Will throw if Redis not configured }; const httpsOptions = hasCertificate @@ -89,109 +87,103 @@ const serverOptions = async (): Promise => { function determineLocal(request: any) { if (request.i18n) { - if (request.state && request.state.language) { - const language = request.state.language; - // Set the language based on the request state - if (language) { - request.i18n.setLocale(language); // Ensure request.i18n is set properly - } else { - request.i18n.setLocale("en"); - } - } else if (request.query && request.query.lang) { - const language = request.query.lang; - // Set the language based on the request state - if (language) { - request.i18n.setLocale(language); // Ensure request.i18n is set properly - } else { - request.i18n.setLocale("en"); - } - } else { - request.i18n.setLocale("en"); + const language = request.state?.language || request.query?.lang || "en"; + request.i18n.setLocale(language); + if (request.query?.lang && request.query.lang !== request.yar.get("lang")) { + request.yar.set("lang", request.query.lang); } } } async function createServer(routeConfig: RouteConfig) { - console.log("*** SERVER CREATING WITH PLUGINS ***") + console.log("*** FORM RUNNER SERVER STARTING ***") const server = hapi.server(await serverOptions()); - // @ts-ignore - const {formFileName, formFilePath, options} = routeConfig; + + // Core plugins if (config.rateLimit) { await server.register(configureRateLimitPlugin(routeConfig)); } + await server.register(pluginLog); await server.register(pluginSession); await server.register(pluginPulse); await server.register(inert); await server.register(Scooter); - await server.register(configureInitialiseSessionPlugin({safelist: config.safelist,})); - // @ts-ignore + await server.register(configureInitialiseSessionPlugin({safelist: config.safelist})); + //@ts-ignore await server.register(configureBlankiePlugin(config)); - // @ts-ignore + //@ts-ignore await server.register(configureCrumbPlugin(config, routeConfig)); await server.register(Schmervice); await server.register(pluginAuth); await server.register(LanguagePlugin); - server.registerService([AdapterCacheService, NotifyService, PayService, WebhookService, AddressService, TranslationLoaderService]); - if (config.isE2EModeEnabled && config.isE2EModeEnabled == "true") { - console.log("E2E Mode enabled") - server.registerService([Schmervice.withName("s3UploadService", MockUploadService),]); + // Register services in dependency order + server.registerService([PreAwardApiService]); + server.registerService([ + AdapterCacheService, + NotifyService, + PayService, + WebhookService, + AddressService, + TranslationLoaderService + ]); + + // Upload service + if (config.isE2EModeEnabled === "true") { + console.log("E2E Mode enabled - using mock upload service") + server.registerService([Schmervice.withName("s3UploadService", MockUploadService)]); } else { server.registerService([S3UploadService]); } - // @ts-ignore + //@ts-ignore server.registerService(AdapterStatusService); - server.ext( - "onPreResponse", - (request: HapiRequest, h: HapiResponseToolkit) => { - const {response} = request; + // Response headers + server.ext("onPreResponse", (request: HapiRequest, h: HapiResponseToolkit) => { + const {response} = request; + + if ("isBoom" in response && response.isBoom && + response?.output?.statusCode >= 500 && + response?.output?.statusCode < 600) { + Sentry.captureException(response); + } + + if ("header" in response && response.header) { + response.header("X-Robots-Tag", "noindex, nofollow"); - if ("isBoom" in response && response.isBoom - && response?.output?.statusCode >= 500 - && response?.output?.statusCode < 600) { - Sentry.captureException(response); - return h.continue; + const existingCsp = response.headers["content-security-policy"]; + if (typeof existingCsp === "string") { + const newCsp = existingCsp.replace( + /connect-src[^;]*/, + `connect-src 'self' https://${config.awsBucketName}.s3.${config.awsRegion}.amazonaws.com/` + ); + response.header("Content-Security-Policy", newCsp); } - if ("header" in response && response.header) { - response.header("X-Robots-Tag", "noindex, nofollow"); - - const existingHeaders = response.headers; - const existingCsp = existingHeaders["content-security-policy"] || ""; - if (typeof existingCsp === "string") { - const newCsp = existingCsp?.replace( - /connect-src[^;]*/, - `connect-src 'self' https://${config.awsBucketName}.s3.${config.awsRegion}.amazonaws.com/` - ); - response.header("Content-Security-Policy", newCsp); - } - - const WEBFONT_EXTENSIONS = /\.(?:eot|ttf|woff|svg|woff2)$/i; - if (!WEBFONT_EXTENSIONS.test(request.url.toString())) { - response.header( - "cache-control", - "private, no-cache, no-store, must-revalidate, max-age=0" - ); - response.header("pragma", "no-cache"); - response.header("expires", "0"); - } else { - response.header("cache-control", "public, max-age=604800, immutable"); - } + // Cache control + const WEBFONT_EXTENSIONS = /\.(?:eot|ttf|woff|svg|woff2)$/i; + if (WEBFONT_EXTENSIONS.test(request.url.toString())) { + response.header("cache-control", "public, max-age=604800, immutable"); + } else { + response.header("cache-control", "private, no-cache, no-store, must-revalidate, max-age=0"); + response.header("pragma", "no-cache"); + response.header("expires", "0"); } - return h.continue; } - ); + + return h.continue; + }); + // Request lifecycle handlers server.ext("onPreHandler", (request: HapiRequest, h: HapiResponseToolkit) => { determineLocal(request); return h.continue; }); server.ext("onRequest", (request: HapiRequest, h: HapiResponseToolkit) => { - // @ts-ignore + //@ts-ignore const {pathname} = getRequestInfo(request); //@ts-ignore request.app.location = pathname; @@ -199,10 +191,10 @@ async function createServer(routeConfig: RouteConfig) { return h.continue; }); - // @ts-ignore + // Register application plugins + //@ts-ignore await server.register(ViewLoaderPlugin); - // @ts-ignore - await server.register(ConfigureFormsPlugin(formFileName, formFilePath, options)); + await server.register(FormRoutesPlugin); await server.register(pluginApplicationStatus); await server.register(publicRouterPlugin); await server.register(errorHandlerPlugin); @@ -213,8 +205,9 @@ async function createServer(routeConfig: RouteConfig) { encoding: "base64json", }); - // Sentry error monitoring + // Error monitoring await Sentry.setupHapiErrorHandler(server); + return server; } diff --git a/runner/src/server/plugins/ConfigureFormsPlugin.ts b/runner/src/server/plugins/ConfigureFormsPlugin.ts deleted file mode 100644 index 242ee604..00000000 --- a/runner/src/server/plugins/ConfigureFormsPlugin.ts +++ /dev/null @@ -1,40 +0,0 @@ -import path from "path"; -import {plugin} from "./engine/MainPlugin"; - -import {loadForms} from "./engine/service/ConfigurationFormsService"; -import {idFromFilename} from "../../../../digital-form-builder/runner/src/server/plugins/engine/helpers"; -import { - FormConfiguration -} from "../../../../../digital-form-builder/runner/src/server/plugins/engine/services/configurationService"; - -import {EngineOptions} from "./engine/types/EngineOptions"; -import {ConfigureEnginePluginType} from "./engine/types/ConfigureEnginePluginType"; -import {config} from "./utils/AdapterConfigurationSchema"; - -const relativeTo = __dirname; - -export const ConfigureFormsPlugin: ConfigureEnginePluginType = ( - formFileName, formFilePath, options?: EngineOptions) => { - let configs: FormConfiguration[]; - - if (formFileName && formFilePath) { - configs = [ - { - configuration: require(path.join(formFilePath, formFileName)), - id: idFromFilename(formFileName) - } - ]; - } else { - configs = loadForms(); - } - - const modelOptions = { - relativeTo, - previewMode: options?.previewMode ?? config.previewMode - }; - - return { - plugin, - options: {modelOptions, configs, previewMode: config.previewMode} - }; -}; diff --git a/runner/src/server/plugins/engine/FormRoutesPlugin.ts b/runner/src/server/plugins/engine/FormRoutesPlugin.ts new file mode 100644 index 00000000..72e3f1ed --- /dev/null +++ b/runner/src/server/plugins/engine/FormRoutesPlugin.ts @@ -0,0 +1,25 @@ +import {RegisterFormAccessApi} from "./api"; + +const LOGGER_DATA = { + class: "FormRoutesPlugin", +} + +/** + * This plugin registers all the routes needed for form access. + * Forms themselves are fetched on-demand from the Pre-Award API. + */ +export const FormRoutesPlugin = { + plugin: { + name: "@communitiesuk/runner/form-routes", + dependencies: "@hapi/vision", + register: async (server: any) => { + console.log({ + ...LOGGER_DATA, + message: `Registering form access routes. Forms will be fetched on-demand from Pre-Award API.` + }); + + // Register all form access routes (GET/POST handlers) + new RegisterFormAccessApi().register(server); + } + } +}; \ No newline at end of file diff --git a/runner/src/server/plugins/engine/MainPlugin.ts b/runner/src/server/plugins/engine/MainPlugin.ts deleted file mode 100644 index 3ec372e8..00000000 --- a/runner/src/server/plugins/engine/MainPlugin.ts +++ /dev/null @@ -1,37 +0,0 @@ -import {Options} from "./types/PluginOptions"; -import {HapiServer} from "../../types"; -import {RegisterFormPublishApi} from "./api"; - - -const LOGGER_DATA = { - class: "MainPlugin", -} - -export const plugin = { - name: "@communitiesuk/runner/engine", - dependencies: "@hapi/vision", - multiple: true, - register: async (server: HapiServer, options: Options) => { - const {configs} = options; - const {adapterCacheService} = server.services([]); - let countOk = 0; - let countError = 0; - for (const config of configs) { - try { - await adapterCacheService.setFormConfiguration(config.id, config, server); - countOk++; - } catch (e) { - countError++; - console.log({ - ...LOGGER_DATA, - message: `[FORM-CACHE] error occurred while loading a form config` - }) - } - } - console.log({ - ...LOGGER_DATA, - message: `[FORM-CACHE] number of forms loaded into cache ok[${countOk}] error[${countError}]` - }) - new RegisterFormPublishApi().register(server, options); - } -}; diff --git a/runner/src/server/plugins/engine/api/RegisterFormAccessApi.ts b/runner/src/server/plugins/engine/api/RegisterFormAccessApi.ts new file mode 100644 index 00000000..dc2bc804 --- /dev/null +++ b/runner/src/server/plugins/engine/api/RegisterFormAccessApi.ts @@ -0,0 +1,220 @@ +import {RegisterApi} from "./RegisterApi"; +import {HapiRequest, HapiResponseToolkit, HapiServer} from "../../../types"; +import Boom from "boom"; +import {PluginUtil} from "../util/PluginUtil"; +import {getValidStateFromQueryParameters} from "../../../../../../digital-form-builder/runner/src/server/plugins/engine/helpers"; +import {PluginSpecificConfiguration} from "@hapi/hapi"; +import {jwtAuthStrategyName} from "../Auth"; +import {config} from "../../utils/AdapterConfigurationSchema"; + +/** + * Registers all routes for accessing and interacting with forms. + * This includes the main form pages, form submission handlers, and file uploads. + * Forms are fetched on-demand from the Pre-Award API via the cache service. + */ +export class RegisterFormAccessApi implements RegisterApi { + + register(server: HapiServer): void { + const {s3UploadService} = server.services([]); + + // Pre-handler to populate form data from query parameters + const queryParamPreHandler = async ( + request: HapiRequest, + h: HapiResponseToolkit + ) => { + const {query} = request; + const {id} = request.params; + const {adapterCacheService} = request.services([]); + + const model = await adapterCacheService.getFormAdapterModel(id, request); + if (!model) { + throw Boom.notFound(`Form '${id}' not found`); + } + + const prePopFields = model.fieldsForPrePopulation; + if (Object.keys(query).length === 0 || Object.keys(prePopFields).length === 0) { + return h.continue; + } + + //@ts-ignore + const state = await adapterCacheService.getState(request); + const newValues = getValidStateFromQueryParameters(prePopFields, query, state); + + if (Object.keys(newValues).length > 0) { + //@ts-ignore + await adapterCacheService.mergeState(request, newValues); + h.request.pre.hasPrepopulatedSessionFromQueryParameter = true; + } + + return h.continue; + }; + + // Pre-handler to check session validity + const checkUserSession = async ( + request: HapiRequest, + h: HapiResponseToolkit + ) => { + const {adapterCacheService} = request.services([]); + //@ts-ignore + const state = await adapterCacheService.getState(request); + + // In production, ensure valid session exists + if (!config.previewMode && !state.callback) { + request.logger.error( + ["checkUserSession"], + `Session expired or invalid for user ${request.yar.id}` + ); + throw Boom.clientTimeout("Session expired"); + } + + return h.continue; + }; + + // Pre-handler for file uploads + const handleFiles = async (request: HapiRequest, h: HapiResponseToolkit) => { + const {path, id} = request.params; + const {adapterCacheService} = request.services([]); + const model = await adapterCacheService.getFormAdapterModel(id, request); + const page = model?.pages.find( + (page) => PluginUtil.normalisePath(page.path) === PluginUtil.normalisePath(path) + ); + //@ts-ignore + return s3UploadService.handleUploadRequest(request, h, page?.pageDef); + }; + + // GET / - Default route + server.route({ + method: "get", + path: "/", + options: { + description: "Default route - redirects to service start page", + }, + handler: async (request: HapiRequest, h: HapiResponseToolkit) => { + if (config.serviceStartPage) { + return h.redirect(config.serviceStartPage); + } + throw Boom.notFound("No default form configured"); + } + }); + + // GET /published - List all published forms (for admin/designer use) + server.route({ + method: "get", + path: "/published", + options: { + description: "Lists all published forms from Pre-Award API", + auth: config.jwtAuthEnabled === "true" ? jwtAuthStrategyName : false + }, + handler: async (request: HapiRequest, h: HapiResponseToolkit) => { + const {adapterCacheService} = request.services([]); + const forms = await adapterCacheService.getFormConfigurations(request); + return h.response(JSON.stringify(forms)).code(200); + } + }); + + // GET /{id} - Form start page + server.route({ + method: "get", + path: "/{id}", + options: { + description: "Form start page", + pre: [ + {method: queryParamPreHandler}, + {method: checkUserSession} + ], + auth: config.jwtAuthEnabled === "true" ? jwtAuthStrategyName : false + }, + handler: async (request: HapiRequest, h: HapiResponseToolkit) => { + const {id} = request.params; + const {adapterCacheService} = request.services([]); + + // This will fetch from Pre-Award API if not cached + const model = await adapterCacheService.getFormAdapterModel(id, request); + + if (model) { + return PluginUtil.getStartPageRedirect(request, h, id, model); + } + throw Boom.notFound(`Form '${id}' not found`); + } + }); + + // GET /{id}/{path*} - Form pages + server.route({ + method: "get", + path: "/{id}/{path*}", + options: { + description: "Form page", + pre: [ + {method: queryParamPreHandler}, + {method: checkUserSession} + ], + auth: config.jwtAuthEnabled === "true" ? jwtAuthStrategyName : false + }, + handler: async (request: HapiRequest, h: HapiResponseToolkit) => { + const {path, id} = request.params; + const {adapterCacheService} = request.services([]); + + const model = await adapterCacheService.getFormAdapterModel(id, request); + + const page = model?.pages.find( + (page) => PluginUtil.normalisePath(page.path) === PluginUtil.normalisePath(path) + ); + + if (page) { + return page.makeGetRouteHandler()(request, h); + } + + if (PluginUtil.normalisePath(path) === "" && model) { + return PluginUtil.getStartPageRedirect(request, h, id, model); + } + + throw Boom.notFound("Page not found"); + } + }); + + // POST /{id}/{path*} - Form submissions + server.route({ + method: "post", + path: "/{id}/{path*}", + options: { + description: "Form submission", + plugins: { + "hapi-rate-limit": { + userPathLimit: 10 + } + }, + payload: { + output: "stream", + parse: true, + multipart: {output: "stream"}, + maxBytes: s3UploadService.fileSizeLimit, + failAction: async (request: HapiRequest, h: HapiResponseToolkit) => { + //@ts-ignore + request.server.plugins.crumb.generate?.(request, h); + return h.continue; + } + }, + pre: [{method: handleFiles}], + auth: config.jwtAuthEnabled === "true" ? jwtAuthStrategyName : false + }, + handler: async (request: HapiRequest, h: HapiResponseToolkit) => { + const {path, id} = request.params; + const {adapterCacheService} = request.services([]); + + const model = await adapterCacheService.getFormAdapterModel(id, request); + + if (model) { + const page = model.pages.find( + (page) => page.path.replace(/^\//, "") === path.replace(/^\//, "") + ); + + if (page) { + return page.makePostRouteHandler()(request, h); + } + } + + throw Boom.notFound("Page not found"); + } + }); + } +} diff --git a/runner/src/server/plugins/engine/api/RegisterFormPublishApi.ts b/runner/src/server/plugins/engine/api/RegisterFormPublishApi.ts deleted file mode 100644 index 75033c32..00000000 --- a/runner/src/server/plugins/engine/api/RegisterFormPublishApi.ts +++ /dev/null @@ -1,322 +0,0 @@ -import {RegisterApi} from "./RegisterApi"; -import {HapiRequest, HapiResponseToolkit, HapiServer} from "../../../types"; -import {Options} from "../types/PluginOptions"; -import {FormPayload} from "../../../../../../digital-form-builder/runner/src/server/plugins/engine/types"; -// @ts-ignore -import Boom from "boom"; -import {PluginUtil} from "../util/PluginUtil"; -import { - getValidStateFromQueryParameters -} from "../../../../../../digital-form-builder/runner/src/server/plugins/engine/helpers"; -import {PluginSpecificConfiguration} from "@hapi/hapi"; -import {jwtAuthStrategyName} from "../Auth"; -import {config} from "../../utils/AdapterConfigurationSchema"; - -export class RegisterFormPublishApi implements RegisterApi { - - /** - * The following publish endpoints (/publish, /published/{id}, /published) - * are used from the designer for operating in 'preview' mode. - * I.E. Designs saved in the designer can be accessed in the runner for viewing. - * The designer also uses these endpoints as a persistence mechanism for storing and retrieving data - * for its own purposes so if you're changing these endpoints you likely need to go and amend - * the designer too! - */ - register(server: HapiServer, options: Options) { - const {previewMode} = options; - const disabledRouteDetailString = - "A request was made however previewing is disabled. See environment variable details in runner/README.md if this error is not expected."; - - server.route({ - method: "post", - path: "/publish", - options: { - description: "See API-README.md file in the runner/src/server/plugins/engine/api", - }, - handler: async (request: HapiRequest, h: HapiResponseToolkit) => { - const {adapterCacheService} = request.services([]); - // @ts-ignore - if (!previewMode || previewMode==="false") { - request.logger.error( - [`POST /publish`, "previewModeError"], - disabledRouteDetailString - ); - throw Boom.forbidden("Publishing is disabled"); - } - const payload = request.payload as FormPayload; - const {id, configuration} = payload; - - const parsedConfiguration = - typeof configuration === "string" - ? JSON.parse(configuration) - : configuration; - if (parsedConfiguration.configuration) { - await adapterCacheService.setFormConfiguration(id, parsedConfiguration, request.server) - } else { - await adapterCacheService.setFormConfiguration(id, {configuration: parsedConfiguration}, request.server) - } - return h.response({}).code(204); - } - }); - - server.route({ - method: "get", - path: "/published/{id}", - options: { - description: "See API-README.md file in the runner/src/server/plugins/engine/api", - }, - handler: async (request: HapiRequest, h: HapiResponseToolkit) => { - const {id} = request.params; - // @ts-ignore - if (!previewMode || previewMode==="false") { - request.logger.error( - [`GET /published/${id}`, "previewModeError"], - disabledRouteDetailString - ); - throw Boom.unauthorized("publishing is disabled"); - } - const {adapterCacheService} = request.services([]); - const form = await adapterCacheService.getFormAdapterModel(id, request); - if (!form) { - return h.response({}).code(204); - } - const {values} = await adapterCacheService.getFormAdapterModel(id, request); - return h.response(JSON.stringify({id, values})).code(200); - } - }); - - server.route({ - method: "get", - path: "/published", - options: { - description: "See API-README.md file in the runner/src/server/plugins/engine/api", - }, - handler: async (request: HapiRequest, h: HapiResponseToolkit) => { - const {adapterCacheService} = request.services([]); - // @ts-ignore - if (!previewMode || previewMode==="false") { - request.logger.error( - [`GET /published`, "previewModeError"], - disabledRouteDetailString - ); - throw Boom.unauthorized("publishing is disabled."); - } - return h - .response(JSON.stringify(await adapterCacheService.getFormConfigurations(request))) - .code(200); - } - }); - - server.route({ - method: "get", - path: "/", - options: { - description: "See API-README.md file in the runner/src/server/plugins/engine/api", - }, - handler: async (request: HapiRequest, h: HapiResponseToolkit) => { - const {adapterCacheService} = request.services([]); - const model = await adapterCacheService.getFormAdapterModel("components", request); - if (model) { - return PluginUtil.getStartPageRedirect(request, h, "components", model); - } - if (config.serviceStartPage) { - return h.redirect(config.serviceStartPage); - } - throw Boom.notFound("No default form found"); - } - }); - - const queryParamPreHandler = async ( - request: HapiRequest, - h: HapiResponseToolkit - ) => { - const {query} = request; - const {id} = request.params; - const {adapterCacheService} = request.services([]); - const model = await adapterCacheService.getFormAdapterModel(id, request); - if (!model) { - throw Boom.notFound("No form found for id"); - } - - const prePopFields = model.fieldsForPrePopulation; - if ( - Object.keys(query).length === 0 || - Object.keys(prePopFields).length === 0 - ) { - return h.continue; - } - // @ts-ignore - const state = await adapterCacheService.getState(request); - const newValues = getValidStateFromQueryParameters( - prePopFields, - query, - state - ); - // @ts-ignore - await adapterCacheService.mergeState(request, newValues); - if (Object.keys(newValues).length > 0) { - h.request.pre.hasPrepopulatedSessionFromQueryParameter = true; - } - return h.continue; - }; - - /** - * Middleware to check if the user session is still valid. - * - * Changes behaviour only when previewMode is FALSE, meaning PRODUCTION - * If the session is dropped, it will throw a client timeout error - */ - const checkUserSession = async ( - request: HapiRequest, - h: HapiResponseToolkit - ) => { - const {adapterCacheService} = request.services([]); - - // @ts-ignore - const state = await adapterCacheService.getState(request); - - // @ts-ignore isNotPreview is always false on production - const isNotPreview = !previewMode || previewMode==="false" - - if (isNotPreview && !state.callback) { - // if you are here the session likely dropped - request.logger.error(["checkUserSession"], `Session expired ${request.yar.id}`); - - throw Boom.clientTimeout("Session expired"); - } - - return h.continue; - } - - server.route({ - method: "get", - path: "/{id}", - options: { - description: "See API-README.md file in the runner/src/server/plugins/engine/api", - pre: [ - { - method: queryParamPreHandler - }, - { - method: checkUserSession - } - ] - }, - handler: async (request: HapiRequest, h: HapiResponseToolkit) => { - const {id} = request.params; - const {adapterCacheService} = request.services([]); - const model = await adapterCacheService.getFormAdapterModel(id, request); - if (model) { - return PluginUtil.getStartPageRedirect(request, h, id, model); - } - throw Boom.notFound("No form found for id"); - } - }); - - const getOptions: any = { - method: "get", - path: "/{id}/{path*}", - options: { - description: "See API-README.md file in the runner/src/server/plugins/engine/api", - pre: [ - { - method: queryParamPreHandler - }, - { - method: checkUserSession - } - ], - }, - handler: async (request: HapiRequest, h: HapiResponseToolkit) => { - const {path, id} = request.params; - const {adapterCacheService} = request.services([]); - const model = await adapterCacheService.getFormAdapterModel(id, request); - const page = model?.pages.find( - (page) => PluginUtil.normalisePath(page.path) === PluginUtil.normalisePath(path) - ); - if (page) { - return page.makeGetRouteHandler()(request, h); - } - if (PluginUtil.normalisePath(path) === "" && model) { - return PluginUtil.getStartPageRedirect(request, h, id, model); - } - throw Boom.notFound("No form or page found"); - } - } - - // TODO: Stop being naughty! Conditionally disabling auth for pre-prod envs is a temporary measure for getting - // FAB into production - if (config.jwtAuthEnabled && config.jwtAuthEnabled === "true") { - getOptions.options.auth = jwtAuthStrategyName - } - - server.route(getOptions); - - const {s3UploadService} = server.services([]); - - const handleFiles = async (request: HapiRequest, h: HapiResponseToolkit) => { - const {path, id} = request.params; - const {adapterCacheService} = request.services([]); - const model = await adapterCacheService.getFormAdapterModel(id, request); - const page = model?.pages.find( - (page) => PluginUtil.normalisePath(page.path) === PluginUtil.normalisePath(path) - ); - // @ts-ignore - return s3UploadService.handleUploadRequest(request, h, page.pageDef); - }; - - const postHandler = async ( - request: HapiRequest, - h: HapiResponseToolkit - ) => { - const {path, id} = request.params; - const {adapterCacheService} = request.services([]); - const model = await adapterCacheService.getFormAdapterModel(id, request); - - if (model) { - const page = model.pages.find( - (page) => page.path.replace(/^\//, "") === path.replace(/^\//, "") - ); - - if (page) { - return page.makePostRouteHandler()(request, h); - } - } - - throw Boom.notFound("No form of path found"); - }; - - let postConfig: any = { - method: "post", - path: "/{id}/{path*}", - options: { - description: "See API-README.md file in the runner/src/server/plugins/engine/api", - plugins: { - "hapi-rate-limit": { - userPathLimit: 10 - } - }, - payload: { - output: "stream", - parse: true, - multipart: {output: "stream"}, - maxBytes: s3UploadService.fileSizeLimit, - failAction: async (request: HapiRequest, h: HapiResponseToolkit) => { - // @ts-ignore - request.server.plugins.crumb.generate?.(request, h); - return h.continue; - } - }, - pre: [{method: handleFiles}], - handler: postHandler, - } - } - if (config.jwtAuthEnabled && config.jwtAuthEnabled === "true") { - postConfig.options.auth = jwtAuthStrategyName - } - server.route(postConfig); - - } - - -} diff --git a/runner/src/server/plugins/engine/api/index.ts b/runner/src/server/plugins/engine/api/index.ts index 137726a5..f608c71b 100644 --- a/runner/src/server/plugins/engine/api/index.ts +++ b/runner/src/server/plugins/engine/api/index.ts @@ -2,4 +2,4 @@ export {RegisterSessionApi} from "./RegisterSessionApi" export {RegisterApi} from "./RegisterApi" export {RegisterApplicationStatusApi} from "./RegisterApplicationStatusApi" export {RegisterPublicApi} from "./RegisterPublicApi" -export {RegisterFormPublishApi} from "./RegisterFormPublishApi" +export {RegisterFormAccessApi} from "./RegisterFormAccessApi" \ No newline at end of file diff --git a/runner/src/server/services/AdapterCacheService.ts b/runner/src/server/services/AdapterCacheService.ts index 4bd10fa4..c27769af 100644 --- a/runner/src/server/services/AdapterCacheService.ts +++ b/runner/src/server/services/AdapterCacheService.ts @@ -2,26 +2,20 @@ import {CacheService} from "../../../../digital-form-builder/runner/src/server/s import Jwt from "@hapi/jwt"; import {DecodedSessionToken} from "../../../../digital-form-builder/runner/src/server/plugins/initialiseSession/types"; import {config} from "../plugins/utils/AdapterConfigurationSchema"; -// @ts-ignore import CatboxRedis from "@hapi/catbox-redis"; -// @ts-ignore -import CatboxMemory from "@hapi/catbox-memory"; -// @ts-ignore -const Catbox = require('@hapi/catbox'); - import Redis from "ioredis"; -// @ts-ignore import Crypto from 'crypto'; import {HapiRequest, HapiServer} from "../types"; import {AdapterFormModel} from "../plugins/engine/models"; import Boom from "boom"; import {FormConfiguration} from "@xgovformbuilder/model"; -import {AdapterSchema} from "@communitiesuk/model"; +import {PreAwardApiService} from "./PreAwardApiService"; const partition = "cache"; const LOGGER_DATA = { class: "AdapterCacheService", } + const { redisHost, redisPort, @@ -30,87 +24,65 @@ const { isSingleRedis, sessionTimeout, } = config; -let redisUri; +let redisUri; if (process.env.FORM_RUNNER_ADAPTER_REDIS_INSTANCE_URI) { redisUri = process.env.FORM_RUNNER_ADAPTER_REDIS_INSTANCE_URI; } export const FORMS_KEY_PREFIX = "forms:cache:" +const FORMS_SESSION_PREFIX = "forms:session:" enum ADDITIONAL_IDENTIFIER { Confirmation = ":confirmation", } export class AdapterCacheService extends CacheService { + private redisClient: Redis; constructor(server: HapiServer) { //@ts-ignore super(server); - //@ts-ignore - server.app.redis = this.getRedisClient() - //@ts-ignore - if (!server.app.redis) { - // starting up the in memory cache - this.cache.client.start(); - //@ts-ignore - server.app.inMemoryFormKeys = [] + + this.redisClient = this.getRedisClient(); + if (!this.redisClient) { + throw new Error("Redis is required for Form Runner. Please configure Redis connection."); } + + //@ts-ignore + server.app.redis = this.redisClient; } async activateSession(jwt, request) { - request.logger.info(`[ACTIVATE-SESSION] jwt ${jwt}`); const initialisedSession = await this.cache.get(this.JWTKey(jwt)); - request.logger.info(`[ACTIVATE-SESSION] session details ${initialisedSession}`); const {decoded} = Jwt.token.decode(jwt); const {payload}: { payload: DecodedSessionToken } = decoded; const userSessionKey = {segment: partition, id: `${request.yar.id}:${payload.group}`}; - request.logger.info(`[ACTIVATE-SESSION] session metadata ${userSessionKey}`); const {redirectPath} = await super.activateSession(jwt, request); - let redirectPathNew = redirectPath + let redirectPathNew = redirectPath; const form_session_identifier = initialisedSession.metadata?.form_session_identifier; if (form_session_identifier) { userSessionKey.id = `${userSessionKey.id}:${form_session_identifier}`; redirectPathNew = `${redirectPathNew}?form_session_identifier=${form_session_identifier}`; } - if (config.overwriteInitialisedSession) { - request.logger.info("[ACTIVATE-SESSION] Replacing user session with initialisedSession"); - this.cache.set(userSessionKey, initialisedSession, sessionTimeout); - } else { - const currentSession = await this.cache.get(userSessionKey); - const mergedSession = { - ...currentSession, - ...initialisedSession, - }; - request.logger.info("[ACTIVATE-SESSION] Merging user session with initialisedSession"); - this.cache.set(userSessionKey, mergedSession, sessionTimeout); - } - request.logger.info(`[ACTIVATE-SESSION] redirect ${redirectPathNew}`); - const key = this.JWTKey(jwt); - request.logger.info(`[ACTIVATE-SESSION] drop key ${JSON.stringify(key)}`); - await this.cache.drop(key); - return { - redirectPath: redirectPathNew, - }; + const mergedSession = config.overwriteInitialisedSession + ? initialisedSession + : {...(await this.cache.get(userSessionKey)), ...initialisedSession}; + + this.cache.set(userSessionKey, mergedSession, sessionTimeout); + await this.cache.drop(this.JWTKey(jwt)); + + return {redirectPath: redirectPathNew}; } - /** - * The key used to store user session data against. - * If there are multiple forms on the same runner instance, for example `form-a` and `form-a-feedback` this will prevent CacheService from clearing data from `form-a` if a user gave feedback before they finished `form-a` - * - * @param request - hapi request object - * @param additionalIdentifier - appended to the id - */ //@ts-ignore Key(request: HapiRequest, additionalIdentifier?: ADDITIONAL_IDENTIFIER) { let id = `${request.yar.id}:${request.params.id}`; - if (request.query.form_session_identifier) { id = `${id}:${request.query.form_session_identifier}`; } - request.logger.info(`[ACTIVATE-SESSION] session key ${id} and segment is ${partition}`); return { segment: partition, id: `${id}${additionalIdentifier ?? ""}`, @@ -118,285 +90,225 @@ export class AdapterCacheService extends CacheService { } /** - * handling form configuration's in a redis cache to easily distribute them among instance - * * If given fom id is not available, it will generate a hash based on the configuration, And it will be saved in redis cache - * * If hash is change then updating the redis - * @param formId form id - * @param configuration form definition configurations - * @param server server object + * Main entry point for retrieving a form model. + * Implements session-based cache validation strategy. */ - async setFormConfiguration(formId: string, configuration: any, server: HapiServer) { - if (formId && configuration) { - //@ts-ignore - if (server.app.redis) { - await this.addConfigurationsToRedisCache(server, configuration, formId); - } else { - await this.addConfigurationIntoInMemoryCache(configuration, formId, server); - } - } - } - - private async addConfigurationIntoInMemoryCache(configuration: any, formId: string, server: HapiServer) { - const hashValue = Crypto.createHash('sha256').update(JSON.stringify(configuration)).digest('hex') - try { - const jsonDataString = await this.cache.get(`${FORMS_KEY_PREFIX}${formId}`); - if (jsonDataString === null) { - // Adding new config into redis cache service with the hash value - const stringConfig = JSON.stringify({ - ...configuration, - id: configuration.id, - hash: hashValue + async getFormAdapterModel(formId: string, request: HapiRequest) { + const {translationLoaderService} = request.services([]); + const translations = translationLoaderService.getTranslations(); + const cacheKey = `${FORMS_KEY_PREFIX}${formId}`; + const sessionCacheKey = `${FORMS_SESSION_PREFIX}${request.yar.id}:${formId}`; + + // Check if we've already validated this form in this user's session + const sessionValidated = await this.redisClient.get(sessionCacheKey); + + // Try to get from cache first + let jsonDataString = await this.redisClient.get(cacheKey); + let configObj = null; + + if (jsonDataString !== null) { + configObj = JSON.parse(jsonDataString); + + // Only validate if we haven't already validated in this session + if (!sessionValidated) { + request.logger.info({ + ...LOGGER_DATA, + message: `First access of form ${formId} in session ${request.yar.id}, validating cache` }); - //@ts-ignore - server.app.inMemoryFormKeys.push(`${FORMS_KEY_PREFIX}${formId}`) - // Adding data into redis cache - await this.cache.set(`${FORMS_KEY_PREFIX}${formId}`, stringConfig, {expiresIn: 0}); - } else { - // Redis has the data and gets current data set to check hash - const configObj = JSON.parse(jsonDataString); - if (configObj && configObj.hash && hashValue !== configObj.hash) { - // if hash function is change then updating the configuration - const stringConfig = JSON.stringify({ - ...configuration, - id: configuration.id, - hash: hashValue + + const isValid = await this.validateCachedForm(formId, configObj.hash, request); + + if (!isValid) { + request.logger.info({ + ...LOGGER_DATA, + message: `Cache stale for form ${formId}, fetching fresh version` }); - await this.cache.set(`${FORMS_KEY_PREFIX}${formId}`, stringConfig, {expiresIn: 0}); - } - } - } catch (error) { - console.log(error); - } - } - - private async addConfigurationsToRedisCache(server: HapiServer, configuration: any, formId: string) { - //@ts-ignore - const redisClient: Redis = server.app.redis - const hashValue = Crypto.createHash('sha256').update(JSON.stringify(configuration)).digest('hex') - if (redisClient) { - const jsonDataString = await redisClient.get(`${FORMS_KEY_PREFIX}${formId}`); - if (jsonDataString === null) { - // Adding new config into redis cache service with the hash value - const stringConfig = JSON.stringify({ - ...configuration, - id: configuration.id, - hash: hashValue - }); - // Adding data into redis cache - await redisClient.set(`${FORMS_KEY_PREFIX}${formId}`, stringConfig); - } else { - // Redis has the data and gets current data set to check hash - const configObj = JSON.parse(jsonDataString); - if (configObj && configObj.hash && hashValue !== configObj.hash) { - // if hash function is change then updating the configuration - const stringConfig = JSON.stringify({ - ...configuration, - id: configuration.id, - hash: hashValue + + const freshConfig = await this.fetchAndCacheForm(formId, request); + if (freshConfig) { + configObj = freshConfig; + } + } else { + request.logger.info({ + ...LOGGER_DATA, + message: `Cache validated successfully for form ${formId}` }); - await redisClient.set(`${FORMS_KEY_PREFIX}${formId}`, stringConfig); } + + // Mark as validated for this session (expires with session timeout) + await this.redisClient.setex(sessionCacheKey, sessionTimeout / 1000, "validated"); } - } - } - - async getFormAdapterModel(formId: string, request: HapiRequest) { - //@ts-ignore - if (request.server.app.redis) { - return await this.getConfigurationFromRedisCache(request, formId); } else { - return await this.getConfigurationFromInMemoryCache(request, formId); - } - } - - private async getConfigurationFromInMemoryCache(request: HapiRequest, formId: string) { - const {translationLoaderService} = request.services([]); - const translations = translationLoaderService.getTranslations(); - const jsonDataString = await this.cache.get(`${FORMS_KEY_PREFIX}${formId}`); - if (jsonDataString !== null) { - const configObj = JSON.parse(jsonDataString); - return new AdapterFormModel(configObj.configuration, { - basePath: configObj.id ? configObj.id : formId, - hash: configObj.hash, - previewMode: true, - translationEn: translations.en, - translationCy: translations.cy - }) + // Cache miss - fetch from Pre-Award API + request.logger.info({ + ...LOGGER_DATA, + message: `Cache miss for form ${formId}, fetching from Pre-Award API` + }); + + configObj = await this.fetchAndCacheForm(formId, request); + + if (!configObj) { + throw Boom.notFound(`Form '${formId}' not found`); + } + + // Mark as validated for this session + await this.redisClient.setex(sessionCacheKey, sessionTimeout / 1000, "validated"); } - request.logger.error({ - ...LOGGER_DATA, - message: `[FORM-CACHE] Cannot find the form ${formId}` + + return new AdapterFormModel(configObj.configuration, { + basePath: configObj.id || formId, + hash: configObj.hash, + previewMode: config.previewMode, + translationEn: translations.en, + translationCy: translations.cy }); - throw Boom.notFound("Cannot find the given form"); } - private async getConfigurationFromRedisCache(request: HapiRequest, formId: string) { - //@ts-ignore - const redisClient: Redis = request.server.app.redis - const {translationLoaderService} = request.services([]); - const translations = translationLoaderService.getTranslations(); - const jsonDataString = await redisClient.get(`${FORMS_KEY_PREFIX}${formId}`); - if (jsonDataString !== null) { - const configObj = JSON.parse(jsonDataString); - return new AdapterFormModel(configObj.configuration, { - basePath: configObj.id ? configObj.id : formId, - hash: configObj.hash, - previewMode: true, - translationEn: translations.en, - translationCy: translations.cy - }) + /** + * Validates cached form against Pre-Award API. + */ + private async validateCachedForm(formId: string, cachedHash: string, request: HapiRequest): Promise { + try { + const {preAwardApiService} = request.services([]); + const currentHash = await preAwardApiService.getFormHash(formId, request); + return currentHash === cachedHash; + } catch (error) { + // If we can't validate, assume cache is valid + request.logger.warn({ + ...LOGGER_DATA, + message: `Could not validate cache for form ${formId}, using cached version` + }); + return true; } - request.logger.error({ - ...LOGGER_DATA, - message: `[FORM-CACHE] Cannot find the form ${formId}` - }); - throw Boom.notFound("Cannot find the given form"); } - async getFormConfigurations(request: HapiRequest) { - //@ts-ignore - if (request.server.app.redis) { - return await this.getFormDisplayConfigurationsFromRedisCache(request); - } else { - return await this.getFormConfigurationsFromInMemoryCache(request); + /** + * Fetches form from Pre-Award API and caches it. + */ + private async fetchAndCacheForm(formId: string, request: HapiRequest): Promise { + try { + const {preAwardApiService} = request.services([]); + const formData = await preAwardApiService.getPublishedForm(formId, request); + + if (!formData) { + return null; + } + + // Recursively sort keys to match Python's sort_keys=True + const sortKeys = (obj: any): any => { + if (!obj || typeof obj !== 'object') return obj; + if (Array.isArray(obj)) return obj.map(sortKeys); + return Object.keys(obj).sort().reduce((acc, key) => { + acc[key] = sortKeys(obj[key]); + return acc; + }, {} as any); + }; + + // Create JSON with sorted keys and no spaces (matching Python's separators) + const jsonString = JSON.stringify(sortKeys(formData)); + const hashValue = Crypto.createHash('md5') + .update(jsonString) + .digest('hex'); + + const configToCache = { + configuration: formData, + id: formId, + hash: hashValue, + fetchedAt: new Date().toISOString() + }; + + // Rest of the function stays the same... + const ttl = 3600; + const cacheKey = `${FORMS_KEY_PREFIX}${formId}`; + await this.redisClient.setex( + cacheKey, + ttl, + JSON.stringify(configToCache) + ); + + request.logger.info({ + ...LOGGER_DATA, + message: `Cached form ${formId} from Pre-Award API` + }); + + return configToCache; + + } catch (error) { + request.logger.error({ + ...LOGGER_DATA, + message: `Failed to fetch form ${formId}`, + error: error + }); + return null; } - } - private async getFormConfigurationsFromInMemoryCache(request: HapiRequest) { - const configs: FormConfiguration[] = [] - //@ts-ignore - for (const key of request.server.app.inMemoryFormKeys) { - const configObj = JSON.parse(await this.cache.get(`${key}`)); - const result = AdapterSchema.validate(configObj.configuration, {abortEarly: false}); - configs.push( - new FormConfiguration( - key.replace(FORMS_KEY_PREFIX, ""), - result.value.name, - undefined, - result.value.feedback?.feedbackForm - ) - ) + /** + * Gets list of available forms from Pre-Award API. + */ + async getFormConfigurations(request: HapiRequest): Promise { + try { + const {preAwardApiService} = request.services([]); + const forms = await preAwardApiService.getAllForms(request); + + return forms + .filter(f => f.is_published) + .map(f => new FormConfiguration(f.name, f.name, undefined, false)); + } catch (error) { + request.logger.error({ + ...LOGGER_DATA, + message: 'Failed to fetch forms list' + }); + return []; } - return configs; } - private async getFormDisplayConfigurationsFromRedisCache(request: HapiRequest) { - //@ts-ignore - const redisClient: Redis = request.server.app.redis; - const keys = await redisClient.keys(`${FORMS_KEY_PREFIX}*`); - const configs: FormConfiguration[] = [] - for (const key of keys) { - const configObj = JSON.parse(await redisClient.get(`${key}`)); - const result = AdapterSchema.validate(configObj.configuration, {abortEarly: false}); - configs.push( - new FormConfiguration( - key.replace(FORMS_KEY_PREFIX, ""), - result.value.name, - undefined, - result.value.feedback?.feedbackForm - ) - ) - } - return configs; + async getConfirmationState(request: HapiRequest) { + const key = this.Key(request, ADDITIONAL_IDENTIFIER.Confirmation); + return this.cache.get(key); } - private getRedisClient() { - if (redisHost || redisUri) { - const redisOptions: { - password?: string; - tls?: {}; - } = {}; + async setConfirmationState(request: HapiRequest, confirmation: any) { + const key = this.Key(request, ADDITIONAL_IDENTIFIER.Confirmation); + await this.cache.set(key, confirmation, sessionTimeout); + } - if (redisPassword) { - redisOptions.password = redisPassword; - } + private getRedisClient(): Redis { + if (!redisHost && !redisUri) { + throw new Error("Redis configuration required. Set REDIS_HOST or FORM_RUNNER_ADAPTER_REDIS_INSTANCE_URI"); + } - if (redisTls) { - redisOptions.tls = {}; - } + const redisOptions: {password?: string; tls?: {}} = {}; + if (redisPassword) redisOptions.password = redisPassword; + if (redisTls) redisOptions.tls = {}; - const client = isSingleRedis - ? new Redis( - redisUri ?? { - host: redisHost, - port: redisPort, - password: redisPassword, - } - ) - : new Redis.Cluster( - [ - { - host: redisHost, - port: redisPort, - }, - ], - { - dnsLookup: (address, callback) => callback(null, address, 4), - redisOptions, - } - ); - return client; - } else { - console.log({ - ...LOGGER_DATA, - message: `[FORM-CACHE] using memory caching`, - }) - } + return isSingleRedis + ? new Redis(redisUri ?? {host: redisHost, port: redisPort, ...redisOptions}) + : new Redis.Cluster( + [{host: redisHost, port: redisPort}], + {dnsLookup: (address, callback) => callback(null, address, 4), redisOptions} + ); } } export const catboxProvider = () => { - /** - * If redisHost doesn't exist, CatboxMemory will be used instead. - * More information at {@link https://hapi.dev/module/catbox/api} - */ - const provider = { - constructor: redisHost || redisUri ? CatboxRedis.Engine : CatboxMemory.Engine, - options: {}, - }; - - if (redisHost || redisUri) { - console.log("Starting redis session management") - const redisOptions: { - password?: string; - tls?: {}; - } = {}; - - if (redisPassword) { - redisOptions.password = redisPassword; - } + if (!redisHost && !redisUri) { + throw new Error("Redis is required for Form Runner"); + } - if (redisTls) { - redisOptions.tls = {}; - } + const redisOptions: {password?: string; tls?: {}} = {}; + if (redisPassword) redisOptions.password = redisPassword; + if (redisTls) redisOptions.tls = {}; - const client = isSingleRedis - ? new Redis( - redisUri ?? { - host: redisHost, - port: redisPort, - password: redisPassword, - } - ) - : new Redis.Cluster( - [ - { - host: redisHost, - port: redisPort, - }, - ], - { - dnsLookup: (address, callback) => callback(null, address, 4), - redisOptions, - } - ); - provider.options = {client, partition}; - console.log(`Redis Url : ${redisUri} session management`); - } else { - console.log("Starting in memory session management") - provider.options = {partition}; - } + const client = isSingleRedis + ? new Redis(redisUri ?? {host: redisHost, port: redisPort, ...redisOptions}) + : new Redis.Cluster( + [{host: redisHost, port: redisPort}], + {dnsLookup: (address, callback) => callback(null, address, 4), redisOptions} + ); - return provider; + return { + constructor: CatboxRedis.Engine, + options: {client, partition} + }; }; diff --git a/runner/src/server/services/PreAwardApiService.ts b/runner/src/server/services/PreAwardApiService.ts new file mode 100644 index 00000000..f2dc33ad --- /dev/null +++ b/runner/src/server/services/PreAwardApiService.ts @@ -0,0 +1,213 @@ +import { HapiServer, HapiRequest } from "../types"; +import { config } from "../plugins/utils/AdapterConfigurationSchema"; +import Boom from "boom"; +import wreck from "@hapi/wreck"; + +export interface FormDefinition { + id: string; + name: string; + created_at?: string; + updated_at?: string; + published_at?: string; + draft_json: any; + published_json: any; + is_published: boolean; +} + +export interface FormHash { + hash: string; +} + +const LOGGER_DATA = { + class: "PreAwardApiService", +} + +export class PreAwardApiService { + private logger: any; + private apiBaseUrl: string; + private wreck: typeof wreck; + + constructor(server: HapiServer) { + this.logger = server.logger; + + // Determine the Pre-Award API base URL from environment or config + const apiHost = process.env.API_HOST || config.API_HOST || 'localhost:3002'; + this.apiBaseUrl = process.env.PRE_AWARD_API_URL || `http://${apiHost}/forms`; + + // Create a wreck client with default options for all requests + this.wreck = wreck.defaults({ + timeout: 10000, + headers: { + 'accept': 'application/json', + 'content-type': 'application/json' + } + }); + + this.logger.info({ + ...LOGGER_DATA, + message: `Service initialized with base URL: ${this.apiBaseUrl}` + }); + } + + /** + * Extracts and formats authentication headers from the incoming request. + * The Pre-Award API uses the same authentication mechanism as the Runner, + * so we forward the user's authentication token to maintain security. + * + * This supports both cookie-based JWT tokens and Authorization headers, + * depending on how the Runner is configured. + */ + private getAuthHeaders(request?: HapiRequest): Record { + const headers: Record = {}; + + if (!request) { + this.logger.warn({ + ...LOGGER_DATA, + message: "No request context provided for authentication" + }); + return headers; + } + + // Handle JWT cookie authentication (primary method) + if (config.jwtAuthEnabled === "true") { + const cookieName = config.jwtAuthCookieName; + + // Check for JWT token in cookies + if (request.state && request.state[cookieName]) { + headers['Cookie'] = `${cookieName}=${request.state[cookieName]}`; + this.logger.debug({ + ...LOGGER_DATA, + message: "Using JWT cookie for Pre-Award API authentication" + }); + } + // Fallback to Authorization header if no cookie + else if (request.headers.authorization) { + headers['Authorization'] = request.headers.authorization; + this.logger.debug({ + ...LOGGER_DATA, + message: "Using Authorization header for Pre-Award API authentication" + }); + } + else { + this.logger.warn({ + ...LOGGER_DATA, + message: "No authentication token found in request" + }); + } + } + + return headers; + } + + /** + * Fetches the published JSON for a specific form. + * This is the primary method used by the cache service when a form + * is requested by a user. It only returns forms that have been + * explicitly published in the Pre-Award system. + */ + async getPublishedForm(name: string, request?: HapiRequest): Promise { + const url = `${this.apiBaseUrl}/${name}/published`; + const authHeaders = this.getAuthHeaders(request); + + this.logger.info({ + ...LOGGER_DATA, + message: `Fetching published form: ${name}`, + url: url + }); + + try { + const { payload } = await this.wreck.get(url, { + headers: { + ...authHeaders, + 'accept': 'application/json' + }, + json: true + }); + + this.logger.info({ + ...LOGGER_DATA, + message: `Successfully fetched published form: ${name}` + }); + + // The API returns the published_json object directly + return payload; + + } catch (error: any) { + // Handle 404 - form doesn't exist or isn't published + if (error.output?.statusCode === 404) { + this.logger.info({ + ...LOGGER_DATA, + message: `Form ${name} not found or not published in Pre-Award API` + }); + return null; + } + + // Handle authentication failures + if (error.output?.statusCode === 401 || error.output?.statusCode === 403) { + this.logger.error({ + ...LOGGER_DATA, + message: `Authentication failed when fetching form ${name}`, + statusCode: error.output?.statusCode + }); + throw Boom.unauthorized('Failed to authenticate with Pre-Award API'); + } + + // Handle other errors (network, timeout, server errors) + this.logger.error({ + ...LOGGER_DATA, + message: `Failed to fetch published form ${name}`, + error: error.message + }); + + // Don't expose internal error details to the client + throw Boom.serverUnavailable('Pre-Award API is temporarily unavailable'); + } + } + + /** + * Fetches just the hash of a published form. + * This lightweight endpoint allows us to validate our cache without + * downloading the entire form definition. We use this for periodic + * cache freshness checks. + */ + async getFormHash(name: string, request?: HapiRequest): Promise { + const url = `${this.apiBaseUrl}/${name}/hash`; + const authHeaders = this.getAuthHeaders(request); + + try { + const { payload } = await this.wreck.get(url, { + headers: { + ...authHeaders, + 'accept': 'application/json' + }, + json: true, + timeout: 5000 // Shorter timeout for hash checks + }); + + const data = payload as FormHash; + + this.logger.debug({ + ...LOGGER_DATA, + message: `Retrieved hash for form ${name}` + }); + + return data.hash; + + } catch (error: any) { + if (error.output?.statusCode === 404) { + // Form doesn't exist or isn't published - this is normal + return null; + } + + // For hash validation failures, we don't want to fail the entire request. + // We'll continue using the cached version and try again later. + this.logger.warn({ + ...LOGGER_DATA, + message: `Could not fetch hash for form ${name}, will use cached version`, + error: error.message + }); + + return null; + } + } +}