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
10 changes: 10 additions & 0 deletions src/api/SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,16 @@ This will:

**That's it!** Skip to the [Verify Setup](#verify-setup) section below.

### Upgrade an existing database

Do not re-run the full schema against an existing database. Apply any SQL files
in `src/api/migrations/` that have not already been applied, in filename order.
For example, Phase 1 vote idempotency requires:

```bash
mysql -u rcv_user -p rcv_db < src/api/migrations/2026-08-16-vote-idempotency.sql
```

## Manual Setup (Alternative)

If you prefer to set up step-by-step or need to customize the process:
Expand Down
4 changes: 4 additions & 0 deletions src/api/migrations/2026-08-16-vote-idempotency.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
ALTER TABLE `votes`
ADD COLUMN `requestKey` varchar(64) DEFAULT NULL AFTER `group_answers`,
ADD COLUMN `requestHash` char(64) DEFAULT NULL AFTER `requestKey`,
ADD UNIQUE KEY `idx_ballot_request` (`ballotId`, `requestKey`);
3 changes: 3 additions & 0 deletions src/api/setup-database-prod.sql
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,11 @@ CREATE TABLE `votes` (
`date_created` datetime DEFAULT (UTC_TIMESTAMP()),
`fingerprint` varchar(64) NOT NULL DEFAULT '',
`group_answers` json DEFAULT NULL,
`requestKey` varchar(64) DEFAULT NULL,
`requestHash` char(64) DEFAULT NULL,
PRIMARY KEY (`vote_id`),
UNIQUE KEY `NoDuplicates` (`ballotId`,`voteIds`(25),`name`,`ipAddress`,`date_created`),
UNIQUE KEY `idx_ballot_request` (`ballotId`,`requestKey`),
KEY `idx_ballot_fingerprint` (`ballotId`,`fingerprint`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8mb3;

Expand Down
41 changes: 41 additions & 0 deletions src/api/v2/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# API v2

Version 2 endpoints use one JSON envelope for success and failure:

```json
{
"data": {},
"error": null
}
```

```json
{
"data": null,
"error": {
"code": "machine_readable_code",
"message": "Human-readable message"
}
}
```

## `POST /api/v2/votes.php`

The Phase 1 anonymous-vote endpoint accepts:

```json
{
"key": "ballot-shortcode",
"requestId": "client_generated_16_to_64_chars",
"ranking": [42, 17, 23],
"fingerprint": "optional-installation-identifier"
}
```

Candidate names and the ballot ID are derived on the server. A request ID is
scoped to its ballot and can be safely retried with the same ranking. Reusing
it with a different ranking returns `idempotency_conflict`.

This endpoint intentionally stops at the Phase 1 anonymous flow. Ballots that
require a voter name, voter code, or grouping answers return typed states for
the client; collecting those values remains Phase 2 work.
214 changes: 214 additions & 0 deletions src/api/v2/votes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
<?php

require_once __DIR__ . '/../config.php';

header('Content-Type: application/json; charset=utf-8');

function respond(int $status, ?array $data, ?array $error): void
{
http_response_code($status);
echo json_encode(['data' => $data, 'error' => $error]);
exit;
}

function fail(int $status, string $code, string $message, ?array $fields = null): void
{
$error = ['code' => $code, 'message' => $message];
if ($fields !== null) {
$error['fields'] = $fields;
}
respond($status, null, $error);
}

function normalizeRequestId($value): ?string
{
if (!is_string($value)) {
return null;
}

$requestId = trim($value);
if (!preg_match('/^[A-Za-z0-9_-]{16,64}$/', $requestId)) {
return null;
}
return $requestId;
}

function normalizeRanking($value): ?array
{
if (!is_array($value) || count($value) === 0) {
return null;
}

$ids = [];
foreach ($value as $candidateId) {
if (is_int($candidateId)) {
$id = $candidateId;
} elseif (is_string($candidateId) && ctype_digit($candidateId)) {
$id = (int) $candidateId;
} else {
return null;
}

if ($id <= 0 || in_array($id, $ids, true)) {
return null;
}
$ids[] = $id;
}

return $ids;
}

function findIdempotentVote(PDO $dbh, int $ballotId, string $requestId): ?array
{
$statement = $dbh->prepare(
'SELECT vote_id, requestHash FROM votes WHERE ballotId = :ballotId AND requestKey = :requestKey LIMIT 1'
);
$statement->bindValue(':ballotId', $ballotId, PDO::PARAM_INT);
$statement->bindValue(':requestKey', $requestId, PDO::PARAM_STR);
$statement->execute();
$row = $statement->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
}

if (isset($_SERVER['REQUEST_METHOD']) && $_SERVER['REQUEST_METHOD'] !== 'POST') {
fail(405, 'method_not_allowed', 'Use POST to submit a vote.');
}

$input = json_decode(file_get_contents('php://input'), true);
if (!is_array($input)) {
fail(400, 'invalid_json', 'The request body must be a JSON object.');
}

$fields = [];
$key = isset($input['key']) && is_string($input['key']) ? trim($input['key']) : '';
if ($key === '') {
$fields['key'] = 'A ballot shortcode is required.';
}

$requestId = normalizeRequestId($input['requestId'] ?? null);
if ($requestId === null) {
$fields['requestId'] = 'A 16-64 character request ID is required.';
}

$ranking = normalizeRanking($input['ranking'] ?? null);
if ($ranking === null) {
$fields['ranking'] = 'Rank at least one candidate using unique candidate IDs.';
}

if ($fields !== []) {
fail(422, 'validation_failed', 'The vote request is invalid.', $fields);
}

$ballotStatement = $dbh->prepare(
'SELECT id, register, oneDeviceOneVote, isSecure, allowGrouping, voteCutoff FROM ballots WHERE `key` = :key LIMIT 1'
);
$ballotStatement->bindValue(':key', $key, PDO::PARAM_STR);
$ballotStatement->execute();
$ballot = $ballotStatement->fetch(PDO::FETCH_ASSOC);

if (!$ballot) {
fail(404, 'ballot_not_found', 'The ballot could not be found.');
}

$ballotId = (int) $ballot['id'];
$requestHash = hash('sha256', json_encode(['ranking' => $ranking], JSON_UNESCAPED_SLASHES));
$existingVote = findIdempotentVote($dbh, $ballotId, $requestId);
if ($existingVote) {
if (!hash_equals((string) $existingVote['requestHash'], $requestHash)) {
fail(409, 'idempotency_conflict', 'This request ID was already used for a different vote.');
}
respond(200, [
'status' => 'accepted',
'voteId' => (int) $existingVote['vote_id'],
'replayed' => true,
], null);
}

if ($ballot['voteCutoff'] !== null && $ballot['voteCutoff'] < gmdate('Y-m-d H:i:s')) {
fail(409, 'voting_closed', 'Voting has closed for this ballot.');
}

if ((int) $ballot['register'] === 1) {
fail(409, 'voter_name_required', 'This ballot requires a voter name, which is not supported in the anonymous flow.');
}

if ((int) $ballot['isSecure'] === 1) {
fail(409, 'secure_code_required', 'This ballot requires a voter code.');
}

if ((int) $ballot['allowGrouping'] === 1) {
fail(409, 'group_answers_required', 'This ballot requires voter questions.');
}

$fingerprint = isset($input['fingerprint']) && is_string($input['fingerprint'])
? substr(trim($input['fingerprint']), 0, 64)
: '';

if ((int) $ballot['oneDeviceOneVote'] === 1) {
if ($fingerprint === '') {
fail(422, 'fingerprint_required', 'A device identifier is required for this ballot.');
}

$duplicateStatement = $dbh->prepare(
'SELECT vote_id FROM votes WHERE ballotId = :ballotId AND fingerprint = :fingerprint LIMIT 1'
);
$duplicateStatement->bindValue(':ballotId', $ballotId, PDO::PARAM_INT);
$duplicateStatement->bindValue(':fingerprint', $fingerprint, PDO::PARAM_STR);
$duplicateStatement->execute();
if ($duplicateStatement->fetch()) {
fail(409, 'duplicate_device', 'This device has already voted on this ballot.');
}
}

$candidatePlaceholders = implode(',', array_fill(0, count($ranking), '?'));
$candidateStatement = $dbh->prepare(
"SELECT entry_id, name FROM entries WHERE ballotId = ? AND entry_id IN ($candidatePlaceholders)"
);
$candidateStatement->execute(array_merge([$ballotId], $ranking));
$candidateRows = $candidateStatement->fetchAll(PDO::FETCH_ASSOC);
$candidateNames = [];
foreach ($candidateRows as $candidate) {
$candidateNames[(int) $candidate['entry_id']] = (string) $candidate['name'];
}

if (count($candidateNames) !== count($ranking)) {
fail(422, 'invalid_ranking', 'One or more ranked candidates do not belong to this ballot.');
}

$voteNames = array_map(fn (int $candidateId): string => $candidateNames[$candidateId], $ranking);
$voteJson = json_encode($voteNames, JSON_UNESCAPED_SLASHES);
$voteIds = implode(',', $ranking);

try {
$insert = $dbh->prepare(
'INSERT INTO votes '
. '(ballotId, date_created, vote, voteIds, ipAddress, name, fingerprint, group_answers, requestKey, requestHash) '
. 'VALUES (:ballotId, UTC_TIMESTAMP(), :vote, :voteIds, :ipAddress, \'\', :fingerprint, NULL, :requestKey, :requestHash)'
);
$insert->bindValue(':ballotId', $ballotId, PDO::PARAM_INT);
$insert->bindValue(':vote', $voteJson, PDO::PARAM_STR);
$insert->bindValue(':voteIds', $voteIds, PDO::PARAM_STR);
$insert->bindValue(':ipAddress', $_SERVER['REMOTE_ADDR'] ?? '', PDO::PARAM_STR);
$insert->bindValue(':fingerprint', $fingerprint, PDO::PARAM_STR);
$insert->bindValue(':requestKey', $requestId, PDO::PARAM_STR);
$insert->bindValue(':requestHash', $requestHash, PDO::PARAM_STR);
$insert->execute();
$voteId = (int) $dbh->lastInsertId();
} catch (PDOException $exception) {
$existingVote = findIdempotentVote($dbh, $ballotId, $requestId);
if (!$existingVote || !hash_equals((string) $existingVote['requestHash'], $requestHash)) {
error_log('v2 vote insert failed: ' . $exception->getMessage());
fail(500, 'server_error', 'The vote could not be recorded.');
}
respond(200, [
'status' => 'accepted',
'voteId' => (int) $existingVote['vote_id'],
'replayed' => true,
], null);
}

respond(201, [
'status' => 'accepted',
'voteId' => $voteId,
'replayed' => false,
], null);
27 changes: 27 additions & 0 deletions test/contract/live-php-api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,33 @@ afterEach(async () => {
});

describe('live PHP API contracts', () => {
it('records and safely replays an idempotent v2 anonymous vote', async () => {
const ballot = await createBallot();
const candidates = await requestApi('GET', 'get-candidates.php', {
query: { key: ballot.key }
});
const ranking = candidates.json.candidates.map((candidate) => Number(candidate.entry_id));
const body = {
key: ballot.key,
requestId: `contract_request_${Date.now()}`,
ranking
};

const first = await requestApi('POST', 'v2/votes.php', { body });
const replay = await requestApi('POST', 'v2/votes.php', { body });

expect(first.status).toBe(201);
expect(first.json).toMatchObject({
data: { status: 'accepted', replayed: false },
error: null
});
expect(replay.status).toBe(200);
expect(replay.json).toEqual({
data: { ...first.json.data, replayed: true },
error: null
});
});

it('creates a ballot, returns candidates, records a vote, and returns results', async () => {
const ballot = await createBallot();

Expand Down
Loading
Loading