diff --git a/backend/app.js b/backend/app.js index a0c6a52..2b23b46 100644 --- a/backend/app.js +++ b/backend/app.js @@ -16,6 +16,7 @@ import healthRoutes from './routes/healthRoutes.js'; import assessmentRoutes from './routes/assessmentRoutes.js'; import complianceRoutes from './routes/complianceRoutes.js'; import questionnaireRoutes from './routes/questionnaireRoutes.js'; +import questionnairePublicRoutes from './routes/questionnairePublicRoutes.js'; import organizationRoutes from './routes/organizationRoutes.js'; import billingRoutes from './routes/billingRoutes.js'; import { handleStripeWebhook } from './controllers/billingController.js'; @@ -239,9 +240,14 @@ app.use('/api/v1/auth', authRoutes); app.use('/api/v1/organizations', organizationRoutes); app.use('/api/v1/billing', billingRoutes); +// Unguarded: public vendor questionnaire form (token-gated, not workspace auth). +// Mounted ahead of requireActivePlan so a visitor's own session cookie (e.g. the +// workspace owner previewing their own link from a paused-plan org) can't 402 +// a route a vendor with no Retrieva account must be able to reach. +app.use('/api/v1/questionnaires', questionnairePublicRoutes); + // Paid routes — optionalAuth sets req.user when a token is present so the -// plan guard can check it; public sub-routes (e.g. questionnaire respond) -// have no token and pass through to the router's own authenticate. +// plan guard can check it. // authenticate is idempotent so the router's router.use(authenticate) is a // no-op when req.user is already set by optionalAuth. // B2: setTenantContext (after auth) makes the active workspace available to the diff --git a/backend/routes/questionnairePublicRoutes.js b/backend/routes/questionnairePublicRoutes.js new file mode 100644 index 0000000..8a30004 --- /dev/null +++ b/backend/routes/questionnairePublicRoutes.js @@ -0,0 +1,34 @@ +import { Router } from 'express'; +import { getPublicForm, submitResponse } from '../controllers/questionnaireController.js'; +import { validateBody, validateParams } from '../middleware/validate.js'; +import { submitQuestionnaireResponseSchema, tokenParamsSchema } from '../validators/schemas.js'; + +const router = Router(); + +// --------------------------------------------------------------------------- +// Public routes — no authentication, no plan gate (token-based access only). +// Mounted unguarded in app.js so a visitor's own session cookie (e.g. the +// workspace owner previewing their own link) can't trigger requireActivePlan +// for a route a vendor with no Retrieva account must be able to reach. +// --------------------------------------------------------------------------- + +/** + * @route GET /api/v1/questionnaires/respond/:token + * @desc Load the public vendor questionnaire form + * @access Public (token-gated) + */ +router.get('/respond/:token', validateParams(tokenParamsSchema), getPublicForm); + +/** + * @route POST /api/v1/questionnaires/respond/:token + * @desc Save partial or final vendor response + * @access Public (token-gated) + */ +router.post( + '/respond/:token', + validateParams(tokenParamsSchema), + validateBody(submitQuestionnaireResponseSchema), + submitResponse +); + +export default router; diff --git a/backend/routes/questionnaireRoutes.js b/backend/routes/questionnaireRoutes.js index b9c7ef8..0e0226a 100644 --- a/backend/routes/questionnaireRoutes.js +++ b/backend/routes/questionnaireRoutes.js @@ -5,8 +5,6 @@ import { getQuestionnaire, deleteQuestionnaire, sendQuestionnaire, - getPublicForm, - submitResponse, } from '../controllers/questionnaireController.js'; import { authenticate } from '../middleware/auth.js'; import { requireWorkspaceAccess } from '../middleware/workspaceAuth.js'; @@ -14,39 +12,16 @@ import { validateBody, validateParams, validateQuery } from '../middleware/valid import { createQuestionnaireSchema, sendQuestionnaireSchema, - submitQuestionnaireResponseSchema, idParamsSchema, - tokenParamsSchema, listQuestionnairesQuerySchema, } from '../validators/schemas.js'; const router = Router(); -// --------------------------------------------------------------------------- -// Public routes — no authentication required (token-based access only) -// --------------------------------------------------------------------------- - -/** - * @route GET /api/v1/questionnaires/respond/:token - * @desc Load the public vendor questionnaire form - * @access Public (token-gated) - */ -router.get('/respond/:token', validateParams(tokenParamsSchema), getPublicForm); - -/** - * @route POST /api/v1/questionnaires/respond/:token - * @desc Save partial or final vendor response - * @access Public (token-gated) - */ -router.post( - '/respond/:token', - validateParams(tokenParamsSchema), - validateBody(submitQuestionnaireResponseSchema), - submitResponse -); - // --------------------------------------------------------------------------- // Authenticated routes — require JWT + workspace membership +// Public /respond/:token routes live in questionnairePublicRoutes.js, mounted +// unguarded in app.js (ahead of requireActivePlan). // --------------------------------------------------------------------------- /** diff --git a/frontend/src/features/questionnaires/api/questionnaires.ts b/frontend/src/features/questionnaires/api/questionnaires.ts index ee4ecf0..5cae5b7 100644 --- a/frontend/src/features/questionnaires/api/questionnaires.ts +++ b/frontend/src/features/questionnaires/api/questionnaires.ts @@ -68,9 +68,9 @@ export interface CreateQuestionnaireDto { // --------------------------------------------------------------------------- const publicApiBaseURL = - (typeof process !== 'undefined' && process.env?.NEXT_PUBLIC_API_URL + typeof process !== 'undefined' && process.env?.NEXT_PUBLIC_API_URL ? process.env.NEXT_PUBLIC_API_URL - : '') + '/api/v1'; + : '/api/v1'; const publicClient = axios.create({ baseURL: publicApiBaseURL,