Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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
Expand Down
22 changes: 16 additions & 6 deletions backend/docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -57,14 +62,17 @@ 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. |
| POST | `/auth/logout` | Public | Clear the session cookie. |
| 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. |
Expand Down Expand Up @@ -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.
22 changes: 16 additions & 6 deletions backend/docs/TEST-CASES.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
# 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 |
| --- | ---: | --- |
| `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 |
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
2 changes: 2 additions & 0 deletions backend/src/config/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
12 changes: 11 additions & 1 deletion backend/src/controllers/authController.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.' })
Expand Down
1 change: 1 addition & 0 deletions backend/src/controllers/userController.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) }),
Expand Down
38 changes: 36 additions & 2 deletions backend/src/docs/openapi.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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.' },
Expand Down Expand Up @@ -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': {
Expand Down Expand Up @@ -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'],
Expand Down
10 changes: 10 additions & 0 deletions backend/src/middlewares/validators/authSchemas.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
})
5 changes: 5 additions & 0 deletions backend/src/middlewares/validators/userSchemas.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
})
2 changes: 2 additions & 0 deletions backend/src/models/Submission.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions backend/src/models/User.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
4 changes: 2 additions & 2 deletions backend/src/repositories/submissionRepo.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
},
Expand Down
4 changes: 3 additions & 1 deletion backend/src/routes/authRoutes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion backend/src/routes/userRoutes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading