Skip to content

Commit

Permalink
feat(mark): sync journal entries from cacvote-server (#79)
Browse files Browse the repository at this point in the history
  • Loading branch information
eventualbuddha authored Mar 8, 2024
1 parent e8a2a8a commit 064c8f6
Show file tree
Hide file tree
Showing 12 changed files with 501 additions and 94 deletions.
2 changes: 1 addition & 1 deletion apps/cacvote-mark/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
"@votingworks/logging": "workspace:*",
"@votingworks/types": "workspace:*",
"@votingworks/utils": "workspace:*",
"cross-fetch": "^3.1.5",
"cross-fetch": "^4.0.0",
"debug": "4.3.4",
"dotenv": "16.3.1",
"dotenv-expand": "9.0.0",
Expand Down
115 changes: 34 additions & 81 deletions apps/cacvote-mark/backend/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -4,98 +4,51 @@ create table system_settings (
data text not null -- JSON blob
);

create table server_sync_attempts (
create table objects (
id uuid primary key,
creator text not null,
trigger text not null,
status_message text not null,
success boolean,
created_at timestamptz not null default current_timestamp,
completed_at timestamp
);

create table jurisdictions (
id uuid primary key,
name text not null,
created_at timestamptz not null default current_timestamp
);
-- which jurisdiction owns the object. de-normalized out of certificates,
-- e.g. "ca.alameda"
jurisdiction varchar(255) not null,

create table elections (
-- generated on this machine
id uuid primary key,
-- generated on the server, present only if the record has been synced
server_id uuid,
-- generated on a client machine; should match `id` if this record was
-- generated on this machine
client_id uuid not null,
-- ID of the machine this record was originally created on
machine_id text not null,
jurisdiction_id uuid not null references jurisdictions(id),
definition bytea not null,
created_at timestamptz not null default current_timestamp,
-- what type of object is this. de-normalized out of `payload`,
-- e.g. "election"
object_type varchar(255) not null,

unique (client_id, machine_id)
);
-- raw object data, must be JSON with fields `object_type` and `data`
payload bytea not null,

create table registration_requests (
-- generated on this machine
id uuid primary key,
-- generated on the server, present only if the record has been synced
server_id uuid,
-- generated on a client machine; should match `id` if this record was
-- generated on this machine
client_id uuid not null unique,
-- ID of the machine this record was originally created on
machine_id text not null,
jurisdiction_id uuid not null references jurisdictions(id),
-- CAC ID of the person for this record
common_access_card_id uuid not null unique,
given_name text not null,
family_name text not null,
created_at timestamptz not null default current_timestamp,
-- certificates used to sign `payload` to get `signature`
certificates bytea not null,

unique (client_id, machine_id)
);
-- signature of `data` using `certificates`
signature bytea not null,

create table registrations (
-- generated on this machine
id uuid primary key,
-- generated on the server, present only if the record has been synced
server_id uuid,
-- generated on a client machine; should match `id` if this record was
-- generated on this machine
client_id uuid not null,
-- ID of the machine this record was originally created on
machine_id text not null,
jurisdiction_id uuid not null references jurisdictions(id),
-- CAC ID of the person for this record
common_access_card_id uuid not null unique,
registration_request_id uuid not null references registration_requests(id),
election_id uuid not null references elections(id),
precinct_id text not null,
ballot_style_id text not null,
-- server sync timestamp, NULL if not synced
server_synced_at timestamptz,

-- when the object was created
created_at timestamptz not null default current_timestamp,

unique (client_id, machine_id)
-- when the object was deleted, NULL if not deleted
deleted_at timestamptz
);

create table printed_ballots (
-- generated on this machine
create table journal_entries (
id uuid primary key,
-- generated on the server, present only if the record has been synced
server_id uuid,
-- generated on a client machine; should match `id` if this record was
-- generated on this machine
client_id uuid not null,
-- ID of the machine this record was originally created on
machine_id text not null,
-- CAC ID of the person for this record
common_access_card_id uuid not null unique,
common_access_card_certificate bytea not null,
registration_id uuid not null references registrations(id),
cast_vote_record bytea not null,
cast_vote_record_signature bytea not null,
created_at timestamptz not null default current_timestamp,

unique (client_id, machine_id)
-- the object that was created or updated
object_id uuid not null,

-- which jurisdiction owns the object, must match `jurisdiction` in `objects`
jurisdiction varchar(255) not null,

-- the object's type, must match `object_type` in `objects`
object_type varchar(255) not null,

-- action that was taken on the object, e.g. "create" or "update"
action varchar(255) not null,

-- when the action was taken
created_at timestamptz not null default current_timestamp
);
190 changes: 190 additions & 0 deletions apps/cacvote-mark/backend/src/cacvote-server/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { Buffer } from 'buffer';
import { Result, asyncResultBlock, err, ok } from '@votingworks/basics';
import fetch, { Headers, Request } from 'cross-fetch';
import { safeParse } from '@votingworks/types';
import { ZodError, z } from 'zod';
import {
JournalEntry,
JournalEntrySchema,
SignedObject,
Uuid,
UuidSchema,
} from './types';

export type ClientError =
| { type: 'network'; message: string }
| { type: 'schema'; error: ZodError; message: string };

export type ClientResult<T> = Result<T, ClientError>;

export class Client {
constructor(private readonly baseUrl: URL) {}

/**
* Create a new client to connect to the server running on localhost.
*/
static localhost(): Client {
return new Client(new URL('http://localhost:8000'));
}

/**
* Check that the server is responding.
*/
async checkStatus(): Promise<ClientResult<void>> {
const statusResult = await this.get('/api/status');

if (statusResult.isErr()) {
return statusResult;
}

const response = statusResult.ok();
if (!response.ok) {
return err({ type: 'network', message: response.statusText });
}

return ok();
}

/**
* Create an object on the server.
*/
async createObject(signedObject: SignedObject): Promise<ClientResult<Uuid>> {
const postResult = await this.post(
'/api/objects',
JSON.stringify(signedObject)
);

if (postResult.isErr()) {
return postResult;
}

const response = postResult.ok();
if (!response.ok) {
return err({ type: 'network', message: response.statusText });
}

const uuidResult = safeParse(UuidSchema, await response.text());

if (uuidResult.isErr()) {
return err({
type: 'schema',
error: uuidResult.err(),
message: uuidResult.err().message,
});
}

return uuidResult;
}

/**
* Retrieve an object from the server.
*/
async getObjectById(uuid: Uuid): Promise<ClientResult<SignedObject>> {
const SignedObjectRawSchema = z.object({
payload: z.string(),
certificates: z.string(),
signature: z.string(),
});
return asyncResultBlock(async (bail) => {
const response = (await this.get(`/api/objects/${uuid}`)).okOrElse(bail);

if (!response.ok) {
bail({ type: 'network', message: response.statusText });
}

const signedObject = safeParse(
SignedObjectRawSchema,
await response.json()
).okOrElse<ZodError>((error) =>
bail({
type: 'schema',
error,
message: error.message,
})
);

return new SignedObject(
Buffer.from(signedObject.payload, 'base64'),
Buffer.from(signedObject.certificates, 'base64'),
Buffer.from(signedObject.signature, 'base64')
);
});
}

/**
* Get journal entries from the server.
*
* @example
*
* ```ts
* const client = Client.localhost();
*
* // Get all journal entries.
* const journalEntries = await client.getJournalEntries();
*
* // Get journal entries since a specific entry.
* const journalEntriesSince = await client.getJournalEntries(journalEntries[0].getId());
* ```
*/
async getJournalEntries(since?: Uuid): Promise<ClientResult<JournalEntry[]>> {
return asyncResultBlock(async (bail) => {
const response = (
await this.get(`/api/journal-entries${since ? `?since=${since}` : ''}`)
).okOrElse(bail);

if (!response.ok) {
return err({ type: 'network', message: response.statusText });
}

const entries = safeParse(
z.array(JournalEntrySchema),
await response.json()
).okOrElse<ZodError>((error) =>
bail({
type: 'schema',
error,
message: error.message,
})
);

return entries.map(
(entry) =>
new JournalEntry(
entry.id,
entry.objectId,
entry.jurisdiction,
entry.objectType,
entry.action,
entry.createdAt
)
);
});
}

private async get(path: string): Promise<ClientResult<Response>> {
try {
return ok(await fetch(new URL(path, this.baseUrl)));
} catch (error) {
return err({ type: 'network', message: (error as Error).message });
}
}

private async post(
path: string,
body: BodyInit
): Promise<ClientResult<Response>> {
const request = new Request(new URL(path, this.baseUrl), {
method: 'POST',
headers: new Headers({
'Content-Type': 'application/json',
}),
body,
});

try {
return ok(await fetch(request));
} catch (error) {
return err({ type: 'network', message: (error as Error).message });
}
}
}
Loading

0 comments on commit 064c8f6

Please sign in to comment.