diff --git a/backend/README.md b/backend/README.md
index 6195ebd..d60fbcc 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -92,7 +92,9 @@ mounted outside production.
## Operational rules
- Authentication uses an HTTP-only `signpak_token` cookie. Email verification is
- required before login; password reset invalidates existing sessions.
+ followed by consent acceptance before a new account is activated and signed
+ in. Existing contributors must accept the current consent version before
+ submitting; password reset invalidates existing sessions.
- `CLIENT_ORIGIN` controls credentialed CORS and the check for browser
state-changing requests that carry an `Origin` header.
- The global, auth, contact, and upload rate limits can be disabled with
@@ -106,9 +108,10 @@ mounted outside production.
- Reference and demo-video uploads accept browser-reported `width` and `height`
in pixels, each from 0 to 16,384. Zero represents unknown dimensions; these
values are metadata for responsive playback, not server-side media probes.
-- `SUBMISSION_COOLDOWN_MS` defaults to 30 seconds per contributor/video pair.
- Atomic claiming prevents simultaneous duplicate submissions; failed processing
- releases the claim.
+- `SUBMISSION_COOLDOWN_MS` defaults to 30 seconds per contributor/video pair,
+ starting when the server accepts an upload for processing. Atomic claiming
+ prevents simultaneous duplicate submissions; failed processing releases the
+ claim.
- Contact delivery uses Web3Forms. A signed-in account can send from its own
email; quotas are one per account per UTC day and 25 per UTC day globally by
default. Abusive attempts can temporarily restrict a device or IP.
@@ -124,7 +127,7 @@ npm test
The command runs Node's built-in test runner serially across every
`tests/*.test.js` file. The API suites use MongoDB Memory Server unless
`MONGODB_URI_TEST` is supplied. That configured database is dropped by the tests,
-so use only a disposable database. Provider delivery is stubbed. The 57 cases and
+so use only a disposable database. Provider delivery is stubbed. The 60 cases and
their test types are documented in [docs/TEST-CASES.md](docs/TEST-CASES.md).
## Current limitation
diff --git a/backend/docs/API.md b/backend/docs/API.md
index 1cf6cf6..9ba95d6 100644
--- a/backend/docs/API.md
+++ b/backend/docs/API.md
@@ -8,9 +8,14 @@ and `/api-docs.json` in all environments. Swagger UI is disabled in production.
## Authentication and cookies
-Successful login and email verification set an HTTP-only `signpak_token` cookie.
-Signup creates an unverified account and sends a six-digit verification code; the
-account must be verified before login. Browser clients must send requests with
+Successful login and consent acceptance set an HTTP-only `signpak_token` cookie.
+Signup creates a pending, unverified record and sends a six-digit verification
+code. A valid code returns a short-lived consent token but does not establish a
+session or activate the account. `POST /auth/consent` accepts the current notice,
+activates the account, stores the consent version and timestamp, and starts the
+session. `POST /auth/decline-consent` discards the pending signup. Existing
+contributors without current consent are redirected to the notice and cannot
+submit recordings until they accept. Browser clients must send requests with
credentials.
`GET /auth/session` returns the current user or `null` for a visitor. `POST
@@ -57,7 +62,9 @@ All paths below are relative to `/api/v1`.
| GET | `/health` | Public | Report API uptime and database connectivity; returns `503` while the database is unavailable. |
| POST | `/auth/signup` | Public | Create an unverified contributor and send an email code. |
| POST | `/auth/login` | Public | Authenticate and set the session cookie. |
-| POST | `/auth/verify-email` | Public | Verify an email code and establish a session. |
+| POST | `/auth/verify-email` | Public | Verify an email code and return a short-lived consent token. |
+| POST | `/auth/consent` | Public | Accept the current consent notice, activate a pending signup, and start a session. |
+| POST | `/auth/decline-consent` | Public | Discard a pending signup after consent is declined. |
| POST | `/auth/resend-verification` | Public | Request another verification code. |
| POST | `/auth/forgot-password` | Public | Request a password-reset code. |
| POST | `/auth/reset-password` | Public | Reset a password and invalidate older sessions. |
@@ -65,6 +72,7 @@ All paths below are relative to `/api/v1`.
| GET | `/auth/session` | Public | Return the current user or `null`. |
| GET | `/users/me` | Authenticated user | Read the signed-in user's profile. |
| PATCH | `/users/me` | Authenticated user | Update supported profile connections. |
+| POST | `/users/me/consent` | Authenticated user | Accept the current notice for an existing account. |
| GET | `/categories` | Public | List categories. |
| POST | `/categories` | Admin | Create a category. |
| PATCH | `/categories/:id` | Admin | Update a category. |
@@ -162,8 +170,10 @@ administrator can recover by removing a restriction.
- Deleting a category unassigns videos rather than deleting them. Deleting a
reference video keeps submissions associated with that video on record.
- A contributor may submit the same video again after the
- `SUBMISSION_COOLDOWN_MS` cooldown (default 30 seconds). Concurrent submissions
- for the same account/video pair are atomically limited.
+ `SUBMISSION_COOLDOWN_MS` cooldown (default 30 seconds), measured from when the
+ server starts processing the upload request. The submission response includes
+ `cooldownStartedAt` so the client countdown uses the same timestamp. Concurrent
+ submissions for the same account/video pair are atomically limited.
For the detailed OpenAPI schemas and examples, use the raw OpenAPI document or
the development Swagger UI.
diff --git a/backend/docs/TEST-CASES.md b/backend/docs/TEST-CASES.md
index 5e7d6a9..c182007 100644
--- a/backend/docs/TEST-CASES.md
+++ b/backend/docs/TEST-CASES.md
@@ -1,7 +1,7 @@
# Backend test cases
-The backend suite uses Node's built-in test runner. It currently contains **57 test
-cases** across twelve test files. Each named API test suite has its own `.test.js`
+The backend suite uses Node's built-in test runner. It currently contains **60 test
+cases** across thirteen test files. Each named API test suite has its own `.test.js`
file and starts with a fresh test database and account fixtures:
| Test file | Cases | Main test type |
@@ -9,6 +9,7 @@ file and starts with a fresh test database and account fixtures:
| `tests/api-basics.test.js` | 5 | HTTP integration tests |
| `tests/api-demo-video.test.js` | 1 | HTTP integration tests |
| `tests/api-auth.test.js` | 8 | HTTP integration tests |
+| `tests/api-consent.test.js` | 3 | HTTP integration tests |
| `tests/api-profile-connections.test.js` | 1 | HTTP integration tests |
| `tests/api-categories.test.js` | 2 | HTTP integration tests |
| `tests/api-videos.test.js` | 6 | HTTP integration tests |
@@ -62,10 +63,10 @@ their respective test file. Each API case below is an HTTP integration test.
7. **signup validates every field** — Checks required signup fields, password
confirmation, and minimum password length.
-8. **signup sets an httpOnly cookie and never leaks the hash or accepts a role** —
- Checks that signup does not create a session before verification, ignores a
- submitted admin role, verifies email, sets secure cookie attributes, and does
- not expose the password hash.
+8. **signup only creates an account after consent and never leaks the hash or
+ accepts a role** — Checks that signup and email verification do not create a
+ session, ignores a submitted admin role, activates the account after consent,
+ sets secure cookie attributes, and does not expose the password hash.
9. **password reset codes are one-time and replace the old password** — Checks the
generic reset-request response, invalid-code handling, password replacement,
code reuse rejection, and invalidation of the old session.
@@ -228,6 +229,15 @@ routes. The cases are separated by the behavior under test.
Checks cleanup of the stored video when the subsequent poster save fails.
57. **video create removes video and poster if the database write fails** —
Checks cleanup of both stored files when database record creation fails.
+58. **creates the account only after consent and records the acceptance time** —
+ Checks that verification alone cannot log in, current consent activates the
+ account, the acceptance time and version are stored, and consent tokens
+ cannot be replayed.
+59. **declining consent discards the unfinished signup** — Checks the pending
+ account is removed and its email can be used for a new signup.
+60. **legacy accounts without consent cannot submit until they accept** — Checks
+ that older accounts are blocked from submitting until they accept the current
+ notice.
## Running the suite
diff --git a/backend/package-lock.json b/backend/package-lock.json
index 394e50f..0f374a2 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "backend",
- "version": "1.0.2",
+ "version": "1.0.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "backend",
- "version": "1.0.2",
+ "version": "1.0.3",
"license": "MIT",
"dependencies": {
"@getbrevo/brevo": "^6.0.3",
diff --git a/backend/package.json b/backend/package.json
index 9079e7d..b47ef68 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "backend",
- "version": "1.0.2",
+ "version": "1.0.3",
"description": "SignPakCommons API: Express, Node.js and MongoDB",
"main": "server.js",
"scripts": {
diff --git a/backend/src/config/constants.js b/backend/src/config/constants.js
index 676eb8b..9b6428f 100644
--- a/backend/src/config/constants.js
+++ b/backend/src/config/constants.js
@@ -10,5 +10,7 @@ export const VIDEO_STATUS_LIST = Object.values(VIDEO_STATUS)
export const POSTER_MAX_BYTES = 2 * 1024 * 1024
export const MAX_RECORDING_SECONDS = 600
+// Bump when the consent notice text changes materially; contributors are then asked to accept it again.
+export const CONSENT_VERSION = '2026-10'
// Client-reported durations are rounded, so allow a little slack when checking trim ranges.
export const TRIM_TOLERANCE_SECONDS = 0.5
diff --git a/backend/src/controllers/authController.js b/backend/src/controllers/authController.js
index d1fbcdb..4a57b3c 100644
--- a/backend/src/controllers/authController.js
+++ b/backend/src/controllers/authController.js
@@ -15,13 +15,23 @@ export const authController = {
ok(res, user)
}),
+ // A valid code does not log anyone in: it returns a short-lived consent token for the next step.
verifyEmail: asyncHandler(async (req, res) => {
- const { user, token } = await authService.verifyEmail(req.body)
+ ok(res, await authService.verifyEmail(req.body))
+ }),
+
+ acceptConsent: asyncHandler(async (req, res) => {
+ const { user, token } = await authService.completeSignup(req.body)
await authService.recordDevice(user, req.get('X-Device-ID'))
setAuthCookie(res, token)
ok(res, user)
}),
+ declineConsent: asyncHandler(async (req, res) => {
+ await authService.declineSignup(req.body)
+ noContent(res)
+ }),
+
resendVerification: asyncHandler(async (req, res) => {
await authService.resendVerification(req.body.email)
ok(res, { message: 'If the account needs verification, a code will be sent.' })
diff --git a/backend/src/controllers/userController.js b/backend/src/controllers/userController.js
index d6be7dc..0a5dbb6 100644
--- a/backend/src/controllers/userController.js
+++ b/backend/src/controllers/userController.js
@@ -5,6 +5,7 @@ import { noContent, ok } from '../utils/response.js'
export const userController = {
me: (req, res) => ok(res, req.user),
updateMe: asyncHandler(async (req, res) => ok(res, await userService.updateConnections(req.user, req.body.connections))),
+ acceptConsent: asyncHandler(async (req, res) => ok(res, await userService.acceptConsent(req.user, req.body.version))),
list: asyncHandler(async (req, res) => ok(res, await userService.listAll())),
updateStatus: asyncHandler(async (req, res) => ok(res, await userService.updateStatus(req.params.id, req.user, req.body))),
remove: asyncHandler(async (req, res) => { await userService.remove(req.params.id, req.user); noContent(res) }),
diff --git a/backend/src/docs/openapi.js b/backend/src/docs/openapi.js
index 0db3717..236c29e 100644
--- a/backend/src/docs/openapi.js
+++ b/backend/src/docs/openapi.js
@@ -141,6 +141,11 @@ export const openapi = {
role: { type: 'string', enum: ['user', 'admin'] },
status: { type: 'string', enum: ['active', 'suspended'] },
statusReason: { type: 'string' },
+ consent: {
+ type: 'object',
+ description: 'When the contributor accepted the consent notice (null until they do).',
+ properties: { acceptedAt: { type: 'string', format: 'date-time', nullable: true }, version: { type: 'string', nullable: true } },
+ },
connections: {
type: 'object',
properties: {
@@ -254,6 +259,7 @@ export const openapi = {
mirrored: { type: 'boolean' },
duration: { type: 'number' },
submittedAt: { type: 'string', format: 'date-time' },
+ cooldownStartedAt: { type: 'string', format: 'date-time', description: 'When the server accepted the upload for processing and claimed the per-video cooldown.' },
size: { type: 'integer' },
mimeType: { type: 'string', enum: ['video/mp4', 'video/webm', 'video/quicktime'] },
archivePath: { type: 'string', nullable: true, description: 'Included for admins only.' },
@@ -347,9 +353,27 @@ export const openapi = {
'/auth/verify-email': {
post: {
tags: ['Auth'],
- summary: 'Verify an email code and establish a session',
+ summary: 'Verify an email code',
+ description: 'A valid code does not create the account or log anyone in. It returns a short-lived consentToken to use with POST /auth/consent.',
requestBody: { required: true, content: { 'application/json': { schema: ref('VerifyEmailInput') } } },
- responses: { 200: jsonResponse('Verified and logged in', ref('User')), ...authErrors },
+ responses: { 200: jsonResponse('Code accepted', { type: 'object', properties: { consentToken: { type: 'string' } } }), ...authErrors },
+ },
+ },
+ '/auth/consent': {
+ post: {
+ tags: ['Auth'],
+ summary: 'Accept the consent notice, create the account and log in',
+ description: 'Activates the account, stores the acceptance date, time and notice version, and sets the session cookie.',
+ requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['consentToken', 'accepted', 'version'], properties: { consentToken: { type: 'string' }, accepted: { type: 'boolean', enum: [true] }, version: { type: 'string' } } } } } },
+ responses: { 200: jsonResponse('Account created and logged in', ref('User')), ...authErrors, 409: errorResponse },
+ },
+ },
+ '/auth/decline-consent': {
+ post: {
+ tags: ['Auth'],
+ summary: 'Decline the consent notice and discard the unfinished sign-up',
+ requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['consentToken'], properties: { consentToken: { type: 'string' } } } } } },
+ responses: { 204: { description: 'Sign-up discarded' }, ...authErrors },
},
},
'/auth/resend-verification': {
@@ -402,6 +426,16 @@ export const openapi = {
responses: { 200: jsonResponse('Updated user', ref('User')), 401: errorResponse, 422: errorResponse },
},
},
+ '/users/me/consent': {
+ post: {
+ tags: ['Users'],
+ summary: 'Accept the consent notice',
+ description: 'Records the acceptance date and time and the notice version. Submissions are refused with CONSENT_REQUIRED until this is done.',
+ security: userSecurity,
+ requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['accepted', 'version'], properties: { accepted: { type: 'boolean', enum: [true] }, version: { type: 'string' } } } } } },
+ responses: { 200: jsonResponse('Updated user', ref('User')), 401: errorResponse, 409: errorResponse, 422: errorResponse },
+ },
+ },
'/categories': {
get: {
tags: ['Categories'],
diff --git a/backend/src/middlewares/validators/authSchemas.js b/backend/src/middlewares/validators/authSchemas.js
index fd1313b..8075b4b 100644
--- a/backend/src/middlewares/validators/authSchemas.js
+++ b/backend/src/middlewares/validators/authSchemas.js
@@ -30,3 +30,13 @@ export const resetPasswordSchema = z.object({
password: z.string({ error: 'Create a password.' }).min(8, 'Use at least 8 characters.').max(72, 'Use 72 characters or fewer.'),
confirmPassword: z.string(),
}).refine((data) => data.confirmPassword === data.password, { path: ['confirmPassword'], message: 'Passwords do not match.' })
+
+export const consentSchema = z.object({
+ consentToken: z.string({ error: 'This sign-up step has expired. Verify your email again.' }).min(10).max(2000),
+ accepted: z.literal(true, { error: 'You must accept the consent notice to create your account.' }),
+ version: z.string({ error: 'Missing consent version.' }).min(1).max(40),
+})
+
+export const declineConsentSchema = z.object({
+ consentToken: z.string({ error: 'This sign-up step has expired.' }).min(10).max(2000),
+})
diff --git a/backend/src/middlewares/validators/userSchemas.js b/backend/src/middlewares/validators/userSchemas.js
index a76e844..9c6acd8 100644
--- a/backend/src/middlewares/validators/userSchemas.js
+++ b/backend/src/middlewares/validators/userSchemas.js
@@ -24,3 +24,8 @@ const linkedin = z.string().trim().nullable().transform((value, ctx) => {
export const updateMeSchema = z.object({
connections: z.object({ github: github.optional(), linkedin: linkedin.optional() }),
})
+
+export const acceptConsentSchema = z.object({
+ accepted: z.literal(true, { error: 'You must accept the consent notice to continue.' }),
+ version: z.string({ error: 'Missing consent version.' }).min(1).max(40),
+})
diff --git a/backend/src/models/Submission.js b/backend/src/models/Submission.js
index 05ad542..f6e3f8b 100644
--- a/backend/src/models/Submission.js
+++ b/backend/src/models/Submission.js
@@ -10,12 +10,14 @@ const submissionSchema = new mongoose.Schema({
mirrored: { type: Boolean, default: false },
duration: { type: Number, required: true, min: 0 },
recording: { type: storedFileSchema, required: true },
+ cooldownStartedAt: { type: Date },
}, {
timestamps: true,
toJSON: jsonOptions((doc, ret) => {
ret.userId = String(ret.user)
ret.videoId = String(ret.video)
ret.submittedAt = ret.createdAt
+ ret.cooldownStartedAt = ret.cooldownStartedAt || ret.createdAt
ret.size = ret.recording?.size ?? 0
ret.mimeType = ret.recording?.mimeType
ret.archivePath = ret.recording?.archivePath ?? null
diff --git a/backend/src/models/User.js b/backend/src/models/User.js
index 7448259..c10e258 100644
--- a/backend/src/models/User.js
+++ b/backend/src/models/User.js
@@ -21,6 +21,11 @@ const userSchema = new mongoose.Schema({
status: { type: String, enum: ['active', 'suspended'], default: 'active' },
statusReason: { type: String, trim: true, maxlength: 500, default: '' },
lastDeviceId: { type: String, select: false, default: null },
+ // OR-19: the contributor accepts the data-use notice right after verifying their email.
+ consent: {
+ acceptedAt: { type: Date, default: null },
+ version: { type: String, default: null },
+ },
connections: {
github: { type: String, default: null },
linkedin: { type: String, default: null },
diff --git a/backend/src/repositories/submissionRepo.js b/backend/src/repositories/submissionRepo.js
index a1e424e..4c49306 100644
--- a/backend/src/repositories/submissionRepo.js
+++ b/backend/src/repositories/submissionRepo.js
@@ -40,9 +40,9 @@ export const submissionRepo = {
{ $set: { lockedUntil: new Date(now.getTime() + cooldownMs) } },
{ upsert: true },
)
- return true
+ return now
} catch (error) {
- if (isDuplicateKeyError(error)) return false
+ if (isDuplicateKeyError(error)) return null
throw error
}
},
diff --git a/backend/src/routes/authRoutes.js b/backend/src/routes/authRoutes.js
index d9fecf9..c8696ff 100644
--- a/backend/src/routes/authRoutes.js
+++ b/backend/src/routes/authRoutes.js
@@ -3,13 +3,15 @@ import { authController } from '../controllers/authController.js'
import { optionalAuthenticate } from '../middlewares/authenticate.js'
import { authLimiter } from '../middlewares/rateLimiter.js'
import { validate } from '../middlewares/validate.js'
-import { emailOnlySchema, loginSchema, resetPasswordSchema, signupSchema, verifyEmailSchema } from '../middlewares/validators/authSchemas.js'
+import { consentSchema, declineConsentSchema, emailOnlySchema, loginSchema, resetPasswordSchema, signupSchema, verifyEmailSchema } from '../middlewares/validators/authSchemas.js'
const router = Router()
router.post('/signup', authLimiter, validate({ body: signupSchema }), authController.signup)
router.post('/login', authLimiter, validate({ body: loginSchema }), authController.login)
router.post('/verify-email', authLimiter, validate({ body: verifyEmailSchema }), authController.verifyEmail)
+router.post('/consent', authLimiter, validate({ body: consentSchema }), authController.acceptConsent)
+router.post('/decline-consent', authLimiter, validate({ body: declineConsentSchema }), authController.declineConsent)
router.post('/resend-verification', authLimiter, validate({ body: emailOnlySchema }), authController.resendVerification)
router.post('/forgot-password', authLimiter, validate({ body: emailOnlySchema }), authController.forgotPassword)
router.post('/reset-password', authLimiter, validate({ body: resetPasswordSchema }), authController.resetPassword)
diff --git a/backend/src/routes/userRoutes.js b/backend/src/routes/userRoutes.js
index 56e2390..fa93d50 100644
--- a/backend/src/routes/userRoutes.js
+++ b/backend/src/routes/userRoutes.js
@@ -2,12 +2,13 @@ import { Router } from 'express'
import { userController } from '../controllers/userController.js'
import { authenticate } from '../middlewares/authenticate.js'
import { validate } from '../middlewares/validate.js'
-import { updateMeSchema } from '../middlewares/validators/userSchemas.js'
+import { acceptConsentSchema, updateMeSchema } from '../middlewares/validators/userSchemas.js'
const router = Router()
router.use(authenticate)
router.get('/me', userController.me)
router.patch('/me', validate({ body: updateMeSchema }), userController.updateMe)
+router.post('/me/consent', validate({ body: acceptConsentSchema }), userController.acceptConsent)
export default router
diff --git a/backend/src/services/authService.js b/backend/src/services/authService.js
index f820c7d..c2deaab 100644
--- a/backend/src/services/authService.js
+++ b/backend/src/services/authService.js
@@ -1,12 +1,12 @@
import bcrypt from 'bcrypt'
import { createHmac, randomInt, timingSafeEqual } from 'node:crypto'
-import { ROLES } from '../config/constants.js'
+import { CONSENT_VERSION, ROLES } from '../config/constants.js'
import { env } from '../config/env.js'
import { userRepo } from '../repositories/userRepo.js'
import { badRequest, conflict, unauthorized } from '../utils/AppError.js'
import { logger } from '../utils/logger.js'
import { isDuplicateKeyError } from '../utils/mongoose.js'
-import { signToken } from '../utils/tokens.js'
+import { signConsentToken, signToken, verifyConsentToken } from '../utils/tokens.js'
import { sendOTPEmailBrevo } from './external/sendOTPBrevo.js'
// Compared against when the email is unknown, so "no such user" takes as long as "wrong password".
@@ -79,12 +79,36 @@ export const authService = {
await userRepo.save(user)
throw invalidCode()
}
- user.emailVerified = true
+ // A correct code only proves the email address. The account is not created (verified, able to log in
+ // or contribute) until the contributor accepts the consent notice: see completeSignup.
clearOtp(user, 'email-verification')
await userRepo.save(user)
+ return { consentToken: signConsentToken(user.id) }
+ },
+
+ async pendingSignup(consentToken) {
+ const userId = verifyConsentToken(consentToken)
+ const user = userId ? await userRepo.findById(userId) : null
+ if (!user || user.emailVerified || user.role !== ROLES.USER) throw badRequest('This sign-up step has expired. Verify your email again to continue.')
+ return user
+ },
+
+ /** Finishes sign-up: records the consent (date, time, version), activates the account and starts a session. */
+ async completeSignup({ consentToken, version }) {
+ if (version !== CONSENT_VERSION) throw conflict('The consent notice was updated. Reload the page and read the current version.')
+ const user = await this.pendingSignup(consentToken)
+ user.emailVerified = true
+ user.consent = { acceptedAt: new Date(), version: CONSENT_VERSION }
+ await userRepo.save(user)
return { user, token: signToken(user.id, user.authVersion || 0) }
},
+ /** Declining consent means no account: the unfinished sign-up is removed. */
+ async declineSignup({ consentToken }) {
+ const user = await this.pendingSignup(consentToken)
+ await userRepo.remove(user._id)
+ },
+
/**
* Resend and password-reset both used to `await issueOtp(...)`, which calls out to Brevo.
* An unknown email returned instantly (no user, nothing to await); a known email waited on
diff --git a/backend/src/services/submissionService.js b/backend/src/services/submissionService.js
index 797ff8e..b5fd3d3 100644
--- a/backend/src/services/submissionService.js
+++ b/backend/src/services/submissionService.js
@@ -1,9 +1,9 @@
-import { MAX_RECORDING_SECONDS, ROLES, TRIM_TOLERANCE_SECONDS, VIDEO_STATUS } from '../config/constants.js'
+import { CONSENT_VERSION, MAX_RECORDING_SECONDS, ROLES, TRIM_TOLERANCE_SECONDS, VIDEO_STATUS } from '../config/constants.js'
import { env } from '../config/env.js'
import { submissionRepo } from '../repositories/submissionRepo.js'
import { categoryRepo } from '../repositories/categoryRepo.js'
import { videoRepo } from '../repositories/videoRepo.js'
-import { conflict, notFound, validationError } from '../utils/AppError.js'
+import { AppError, conflict, notFound, validationError } from '../utils/AppError.js'
import { sniffVideo } from '../utils/files.js'
import { storageService } from './storage/index.js'
@@ -27,6 +27,9 @@ export const submissionService = {
* once in, stays exactly as submitted.
*/
async create(user, input, upload) {
+ if (user.role === ROLES.USER && user.consent?.version !== CONSENT_VERSION) {
+ throw new AppError(403, 'CONSENT_REQUIRED', 'Accept the consent notice before submitting a recording.')
+ }
if (!upload) throw validationError({ recording: 'Attach your recording.' })
const kind = await sniffVideo(upload.path)
if (!kind) throw validationError({ recording: 'That does not look like an MP4, WebM or MOV video.' })
@@ -37,8 +40,8 @@ export const submissionService = {
const category = await categoryRepo.findById(video.category)
if (!category) throw notFound('That video category is not available.')
- const claimed = await submissionRepo.claimCooldown(user._id, video._id, env.SUBMISSION_COOLDOWN_MS)
- if (!claimed) throw cooling(await submissionRepo.cooldownRemaining(user._id, video._id))
+ const cooldownStartedAt = await submissionRepo.claimCooldown(user._id, video._id, env.SUBMISSION_COOLDOWN_MS)
+ if (!cooldownStartedAt) throw cooling(await submissionRepo.cooldownRemaining(user._id, video._id))
let recording
try {
@@ -52,7 +55,7 @@ export const submissionService = {
})
return await submissionRepo.create({
user: user._id, video: video._id, trimStart: input.trimStart, trimEnd: input.trimEnd,
- mirrored: input.mirrored, duration: input.duration, recording,
+ mirrored: input.mirrored, duration: input.duration, recording, cooldownStartedAt,
})
} catch (error) {
await storageService.remove(recording)
diff --git a/backend/src/services/userService.js b/backend/src/services/userService.js
index 5276caf..10e918a 100644
--- a/backend/src/services/userService.js
+++ b/backend/src/services/userService.js
@@ -1,10 +1,18 @@
-import { ROLES } from '../config/constants.js'
+import { CONSENT_VERSION, ROLES } from '../config/constants.js'
import { submissionRepo } from '../repositories/submissionRepo.js'
import { userRepo } from '../repositories/userRepo.js'
import { storageService } from './storage/index.js'
import { conflict, forbidden, notFound } from '../utils/AppError.js'
export const userService = {
+ /** Records the date and time the contributor accepted the consent notice (kept on the first acceptance of a version). */
+ async acceptConsent(user, version) {
+ if (version !== CONSENT_VERSION) throw conflict('The consent notice was updated. Reload the page and read the current version.')
+ if (user.consent?.acceptedAt && user.consent.version === CONSENT_VERSION) return user
+ user.consent = { acceptedAt: new Date(), version: CONSENT_VERSION }
+ return userRepo.save(user)
+ },
+
async updateConnections(user, connections) {
// Only keys that were sent change; null disconnects.
for (const key of ['github', 'linkedin']) {
diff --git a/backend/src/utils/tokens.js b/backend/src/utils/tokens.js
index af8de5d..51aba6d 100644
--- a/backend/src/utils/tokens.js
+++ b/backend/src/utils/tokens.js
@@ -21,3 +21,16 @@ const cookieOptions = () => ({ httpOnly: true, secure: env.COOKIE_SECURE, sameSi
export const setAuthCookie = (res, token) => res.cookie(AUTH_COOKIE, token, { ...cookieOptions(), maxAge: env.JWT_EXPIRES_DAYS * DAY_MS })
export const clearAuthCookie = (res) => res.clearCookie(AUTH_COOKIE, cookieOptions())
+
+// Short-lived proof that someone just entered a valid email code. It is not a login: the account
+// is only finished (and a session started) once the consent notice is accepted (see authService.completeSignup).
+const CONSENT_TOKEN_MINUTES = 30
+export const signConsentToken = (userId) => jwt.sign({ purpose: 'signup-consent' }, env.JWT_SECRET, { subject: String(userId), expiresIn: `${CONSENT_TOKEN_MINUTES}m`, algorithm: 'HS256' })
+export function verifyConsentToken(token) {
+ try {
+ const payload = jwt.verify(token, env.JWT_SECRET, { algorithms: ['HS256'] })
+ return payload.purpose === 'signup-consent' && payload.sub ? payload.sub : null
+ } catch {
+ return null
+ }
+}
diff --git a/backend/tests/api-auth.test.js b/backend/tests/api-auth.test.js
index 7dbb6f7..014b7c0 100644
--- a/backend/tests/api-auth.test.js
+++ b/backend/tests/api-auth.test.js
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict'
import { after, before, describe, test } from 'node:test'
+import { CONSENT_VERSION } from '../src/config/constants.js'
import { Client, filesIn, jpeg, mp4, recordingForm, videoForm, webm } from './helpers.js'
import { useApiContext } from './apiContext.js'
@@ -26,7 +27,7 @@ describe('auth', () => {
assert.equal(short.body.error.fields.password, 'Use at least 8 characters.')
})
- test('signup sets an httpOnly cookie and never leaks the hash or accepts a role', async () => {
+ test('signup only creates an account after consent and never leaks the hash or accepts a role', async () => {
const c = new Client(base)
eveSession = c
const res = await c.post('/auth/signup', { firstName: 'Eve', surname: 'Hacker', email: 'eve@example.com', password: 'password1', role: 'admin' })
@@ -38,11 +39,17 @@ describe('auth', () => {
assert.equal((await c.post('/auth/verify-email', { email: 'eve@example.com', code: '000000' })).status, 400)
const verified = await c.post('/auth/verify-email', { email: 'eve@example.com', code: deliveredCodes.get('eve@example.com:email-verification') })
assert.equal(verified.status, 200)
- assert.equal(verified.body.data.role, 'user')
- assert.equal(verified.body.data.emailVerified, true)
+ assert.equal(verified.setCookie.length, 0)
+ assert.ok(verified.body.data.consentToken)
+ assert.equal((await c.post('/auth/login', { email: 'eve@example.com', password: 'password1' })).status, 401)
+ assert.equal((await c.post('/auth/consent', { consentToken: 'garbage-garbage', accepted: true, version: CONSENT_VERSION })).status, 400)
+ const accepted = await c.post('/auth/consent', { consentToken: verified.body.data.consentToken, accepted: true, version: CONSENT_VERSION })
+ assert.equal(accepted.status, 200)
+ assert.equal(accepted.body.data.role, 'user')
+ assert.equal(accepted.body.data.emailVerified, true)
assert.equal(res.body.data.passwordHash, undefined)
- assert.match(verified.setCookie[0], /HttpOnly/i)
- assert.match(verified.setCookie[0], /SameSite=Lax/i)
+ assert.match(accepted.setCookie[0], /HttpOnly/i)
+ assert.match(accepted.setCookie[0], /SameSite=Lax/i)
assert.equal((await c.get('/admin/stats')).status, 403)
})
diff --git a/backend/tests/api-consent.test.js b/backend/tests/api-consent.test.js
new file mode 100644
index 0000000..2879379
--- /dev/null
+++ b/backend/tests/api-consent.test.js
@@ -0,0 +1,87 @@
+import assert from 'node:assert/strict'
+import { after, before, describe, test } from 'node:test'
+import { Client, recordingForm, videoForm } from './helpers.js'
+import { CONSENT_VERSION } from '../src/config/constants.js'
+import { useApiContext } from './apiContext.js'
+
+const getContext = useApiContext({ after })
+let base, admin, createVerifiedUser, deliveredCodes
+
+before(async () => {
+ const context = await getContext()
+ ;({ base, admin, createVerifiedUser, deliveredCodes } = context)
+})
+
+const signupAndVerify = async (email) => {
+ const client = new Client(base)
+ const signup = await client.post('/auth/signup', { firstName: 'Lina', surname: 'Raza', email, password: 'password1' })
+ assert.equal(signup.status, 201)
+ const verified = await client.post('/auth/verify-email', {
+ email,
+ code: deliveredCodes.get(`${email}:email-verification`),
+ })
+ assert.equal(verified.status, 200)
+ return { client, token: verified.body.data.consentToken }
+}
+
+describe('consent notice', () => {
+ test('creates the account only after consent and records the acceptance time', async () => {
+ const { client, token } = await signupAndVerify('lina@example.com')
+ assert.equal((await client.get('/users/me')).status, 401)
+ assert.equal((await client.post('/auth/login', { email: 'lina@example.com', password: 'password1' })).status, 401)
+ assert.equal((await client.post('/auth/consent', { consentToken: token, accepted: false, version: CONSENT_VERSION })).status, 422)
+ assert.equal((await client.post('/auth/consent', { consentToken: token, accepted: true, version: 'old' })).status, 409)
+
+ const before = Date.now()
+ const accepted = await client.post('/auth/consent', { consentToken: token, accepted: true, version: CONSENT_VERSION })
+ assert.equal(accepted.status, 200)
+ assert.equal(accepted.body.data.emailVerified, true)
+ assert.equal(accepted.body.data.consent.version, CONSENT_VERSION)
+ assert.ok(new Date(accepted.body.data.consent.acceptedAt).getTime() >= before - 1000)
+ assert.equal((await client.get('/users/me')).status, 200)
+ assert.equal((await new Client(base).post('/auth/login', { email: 'lina@example.com', password: 'password1' })).status, 200)
+ assert.equal((await client.post('/auth/consent', { consentToken: token, accepted: true, version: CONSENT_VERSION })).status, 400)
+ })
+
+ test('declining consent discards the unfinished signup', async () => {
+ const { client, token } = await signupAndVerify('mina@example.com')
+ assert.equal((await client.post('/auth/decline-consent', { consentToken: token })).status, 204)
+ assert.equal((await new Client(base).post('/auth/login', { email: 'mina@example.com', password: 'password1' })).status, 401)
+ const retry = await new Client(base).post('/auth/signup', {
+ firstName: 'Mina',
+ surname: 'Ali',
+ email: 'mina@example.com',
+ password: 'password1',
+ })
+ assert.equal(retry.status, 201)
+ })
+
+ test('legacy accounts without consent cannot submit until they accept', async () => {
+ const user = await createVerifiedUser({ firstName: 'Old', surname: 'Timer', email: 'old@example.com', consent: false })
+ const profile = await user.get('/users/me')
+ assert.equal(profile.body.data.consent.acceptedAt, null)
+
+ const category = await admin.post('/categories', { label: 'Consent greetings', tone: 'blue' })
+ assert.equal(category.status, 201)
+ const upload = await admin.upload('/videos', videoForm({
+ title: 'Consent hello',
+ categoryId: category.body.data.id,
+ status: 'published',
+ durationSec: '3',
+ }))
+ assert.equal(upload.status, 201)
+ const form = () => recordingForm({
+ videoId: upload.body.data.id,
+ trimStart: '0',
+ trimEnd: '2.5',
+ duration: '3',
+ mirrored: 'false',
+ })
+
+ const blocked = await user.upload('/submissions', form())
+ assert.equal(blocked.status, 403)
+ assert.equal(blocked.body.error.code, 'CONSENT_REQUIRED')
+ assert.equal((await user.post('/users/me/consent', { accepted: true, version: CONSENT_VERSION })).status, 200)
+ assert.equal((await user.upload('/submissions', form())).status, 201)
+ })
+})
diff --git a/backend/tests/api-submissions.test.js b/backend/tests/api-submissions.test.js
index d8f5ffc..07cb4f9 100644
--- a/backend/tests/api-submissions.test.js
+++ b/backend/tests/api-submissions.test.js
@@ -77,6 +77,7 @@ describe('submissions and the cooldown rule', () => {
assert.equal(submission.videoId, video1.id)
assert.equal(submission.mirrored, true)
assert.equal(submission.trimEnd, 2.5)
+ assert.ok(Date.parse(submission.cooldownStartedAt) <= Date.parse(submission.submittedAt))
assert.equal(submission.recording, undefined)
assert.equal(filesIn('commons').length, 1)
const mine = await contributor.get('/submissions')
diff --git a/backend/tests/apiContext.js b/backend/tests/apiContext.js
index c84f1b6..7496ee9 100644
--- a/backend/tests/apiContext.js
+++ b/backend/tests/apiContext.js
@@ -1,4 +1,5 @@
import { Client } from './helpers.js'
+import { CONSENT_VERSION } from '../src/config/constants.js'
process.env.NODE_ENV = 'test'
process.env.MONGODB_URI = process.env.MONGODB_URI_TEST || 'mongodb://placeholder/unused'
@@ -85,7 +86,7 @@ async function createApiContext() {
})
if (login.status !== 200) throw new Error(`Test admin login failed: ${login.status}`)
- const createVerifiedUser = async ({ firstName, surname, email, password = 'password1' }) => {
+ const createVerifiedUser = async ({ firstName, surname, email, password = 'password1', consent = true }) => {
const user = new Client(base)
const signup = await user.post('/auth/signup', { firstName, surname, email, password })
if (signup.status !== 201) throw new Error(`Test user signup failed: ${signup.status}`)
@@ -95,6 +96,18 @@ async function createApiContext() {
code: deliveredCodes.get(`${normalizedEmail}:email-verification`),
})
if (verified.status !== 200) throw new Error(`Test user verification failed: ${verified.status}`)
+ const accepted = await user.post('/auth/consent', {
+ consentToken: verified.body.data.consentToken,
+ accepted: true,
+ version: CONSENT_VERSION,
+ })
+ if (accepted.status !== 200) throw new Error(`Test consent failed: ${accepted.status}`)
+ if (!consent) {
+ await mongoose.models.User.updateOne(
+ { email: normalizedEmail },
+ { $set: { 'consent.acceptedAt': null, 'consent.version': null } },
+ )
+ }
return user
}
diff --git a/frontend/README.md b/frontend/README.md
index c3d2eba..d8f3563 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -31,8 +31,9 @@ backend separately, or set `VITE_API_ORIGIN` to another API origin.
| `/demo` | Public | Walkthrough video. |
| `/faq` | Public | Frequently asked questions. |
| `/login`, `/signup` | Signed out | Sign in or register; redirects signed-in users. |
-| `/verify-email`, `/forgot-password` | Signed out | Email verification and password recovery. |
-| `/home` | Signed in | Category overview. |
+| `/verify-email`, `/signup/consent`, `/forgot-password` | Signed out | Email verification, consent before account creation, and password recovery. |
+| `/consent` | Signed in | Accept the current contributor notice; required for older accounts without current consent. |
+| `/home` | Signed in with current consent | Category overview. |
| `/library/:categoryId` | Signed in | Videos in a category. |
| `/watch?v=:videoId` | Signed in | Watch a reference video and record a take. |
| `/watch/edit?v=:videoId` | Signed in | Review, trim, and submit the current take. |
@@ -44,7 +45,17 @@ backend separately, or set `VITE_API_ORIGIN` to another API origin.
Route declarations live in `src/App.jsx`; shared path names and URL builders live
in `src/routes/appRoutes.js`. Auth guards in `src/components/RouteGuards.jsx`
-redirect visitors who do not meet the route's access requirement.
+redirect visitors who do not meet the route's access requirement and route
+contributors without the current consent version through `/consent`.
+
+After email verification, the frontend holds the short-lived consent token in
+the navigation state and offers accept or decline. Accepting creates and signs
+in the account; declining discards the pending signup. The consent version and
+acceptance timestamp are stored by the backend.
+
+When uploading a base video, admins must either select a JPEG, PNG, or WebP
+poster image (up to 2 MB), or explicitly check the option to use a frame
+captured automatically from the video.
## Backend connection
diff --git a/frontend/docs/architecture.md b/frontend/docs/architecture.md
index 6e9b34a..c60a019 100644
--- a/frontend/docs/architecture.md
+++ b/frontend/docs/architecture.md
@@ -36,13 +36,16 @@ page; they are not separate page screens.
- `GuestOnly` allows signed-out visitors through auth pages and sends signed-in
users to their original destination or `/home`.
-- `RequireAuth` waits for the session check and redirects visitors to `/login`.
+- `RequireAuth` waits for the session check, redirects visitors to `/login`, and
+ requires non-admin contributors to accept the current consent notice before
+ accessing protected library and recording routes.
- `RequireAdmin` allows the admin role and otherwise redirects to `/home`.
The route tree includes public landing, demo, and FAQ pages; signed-out login,
-signup, email verification, and password recovery pages; signed-in home, library,
-watch, editor, and profile pages; and admin dashboard, user, video, and category
-pages.
+signup, email verification, consent-before-account-creation, and password
+recovery pages; a signed-in consent page for existing accounts; signed-in home,
+library, watch, editor, and profile pages; and admin dashboard, user, video, and
+category pages.
## State and ownership
@@ -51,7 +54,7 @@ management library.
| Provider | Responsibility and persistence |
| --- | --- |
-| `AuthProvider` | Loads the current session from the API and exposes login, signup, email verification, logout, and connection updates. |
+| `AuthProvider` | Loads the current session from the API and exposes login, signup, email verification, consent completion/decline, logout, and connection updates. |
| `LibraryProvider` | Loads categories, visible videos, and the current user's submissions; provides derived category, contribution, and cooldown state plus admin mutations. |
| `RecordingProvider` | Holds unsubmitted recording blobs and trim/mirror edits in memory, keyed by video ID. These drafts are discarded on submit, replacement, user change, or tab close. |
| `UploadQueueProvider` | Holds up to two submitted recordings in an in-memory queue (one uploading and one waiting). Failed uploads keep their slot for retry or discard; the queue is cleared when the signed-in user changes. |
@@ -78,6 +81,12 @@ The client:
- Converts API-relative media URLs to absolute URLs when `VITE_API_ORIGIN` is
configured.
+Email verification returns a short-lived consent token rather than a session.
+The signup consent screen submits that token and the current notice version;
+only successful acceptance activates and signs in the new account. Declining
+discards the pending signup. Existing contributors without the current consent
+version are routed to `/consent` before using protected contributor pages.
+
Vite proxies `/api` to `http://localhost:5000` for local development. The
optional `VITE_API_ORIGIN` variable selects a separately hosted API; leave it
empty when using the local proxy. `VITE_SUBMISSION_COOLDOWN_MS` sets the UI's
@@ -96,7 +105,9 @@ to the player immediately; a page-level toaster reports waiting, active, failed,
and completed uploads. Failed uploads retain their queue slot until retried or
discarded. Queue data is memory-only, and a `beforeunload` prompt warns if the
tab is closed or reloaded while work remains. The backend stores contributor
-recordings and metadata; it does not physically trim or transcode them.
+recordings and metadata; it does not physically trim or transcode them. The
+per-video cooldown starts when the server accepts an upload for processing, and
+the API's `cooldownStartedAt` timestamp drives the matching client countdown.
### Reference-video cache
@@ -120,9 +131,11 @@ that CDN to apply changes. If processing fails, the UI reports the error and
allows an unchanged original upload.
`inspectVideoFile` reads the browser-reported duration and displayed dimensions
-(including rotation metadata) and captures a poster frame locally. The upload
-stores `durationSec`, `width`, and `height` with the video; unknown dimensions
-are represented as zero. `ResponsiveVideo` uses stored dimensions to size the
+(including rotation metadata) and captures a poster frame locally. For each
+reference video, admins must either select a JPEG, PNG, or WebP poster image
+(up to 2 MB), or check the option to use that captured frame. The upload stores
+`durationSec`, `width`, and `height` with the video; unknown dimensions are
+represented as zero. `ResponsiveVideo` uses stored dimensions to size the
frame before media metadata loads, then prefers the dimensions reported by the
loaded video. This keeps portrait, square, and landscape videos in their native
aspect ratio while fitting the player layout.
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 766f9df..c936c1c 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "frontend",
- "version": "1.0.5",
+ "version": "1.0.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
- "version": "1.0.5",
+ "version": "1.0.6",
"dependencies": {
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
diff --git a/frontend/package.json b/frontend/package.json
index 70b697a..f443496 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
- "version": "1.0.5",
+ "version": "1.0.6",
"type": "module",
"engines": {
"node": ">=20.19.0"
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 0a35054..1085f12 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -18,6 +18,7 @@ import Login from './pages/Login'
import Player from './pages/Player'
import ProfileDashboard from './pages/ProfileDashboard'
import RecordingEditor from './pages/RecordingEditor'
+import Consent from './pages/Consent'
import Signup from './pages/Signup'
import VerifyEmail from './pages/VerifyEmail'
@@ -31,12 +32,14 @@ export default function App() {
Submit this recording? It uploads in the background, so you can keep browsing and record the next video. Once submitted it can't be watched, edited or deleted, but you can record this video again after a short cooldown.
+Submit this recording? It uploads in the background, so you can keep browsing and record the next video. Once submitted it can't be watched, edited or deleted, but you can record this video again after a 30-second cooldown once the upload is accepted.