diff --git a/apps/cacvote-mark/backend/package.json b/apps/cacvote-mark/backend/package.json index 943087f876..c902208341 100644 --- a/apps/cacvote-mark/backend/package.json +++ b/apps/cacvote-mark/backend/package.json @@ -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", diff --git a/apps/cacvote-mark/backend/schema.sql b/apps/cacvote-mark/backend/schema.sql index 7ae965e09e..b2b754381d 100644 --- a/apps/cacvote-mark/backend/schema.sql +++ b/apps/cacvote-mark/backend/schema.sql @@ -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 ); diff --git a/apps/cacvote-mark/backend/src/cacvote-server/client.ts b/apps/cacvote-mark/backend/src/cacvote-server/client.ts new file mode 100644 index 0000000000..0a1f635b00 --- /dev/null +++ b/apps/cacvote-mark/backend/src/cacvote-server/client.ts @@ -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 = Result; + +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> { + 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> { + 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> { + 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((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> { + 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((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> { + 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> { + 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 }); + } + } +} diff --git a/apps/cacvote-mark/backend/src/cacvote-server/types.ts b/apps/cacvote-mark/backend/src/cacvote-server/types.ts new file mode 100644 index 0000000000..7b954617c7 --- /dev/null +++ b/apps/cacvote-mark/backend/src/cacvote-server/types.ts @@ -0,0 +1,105 @@ +// eslint-disable-next-line max-classes-per-file +import { Buffer } from 'buffer'; +import { NewType } from '@votingworks/types'; +import { validate } from 'uuid'; +import { DateTime } from 'luxon'; +import { z } from 'zod'; + +export type Uuid = NewType; + +export const UuidSchema = z.string().refine(validate, { + message: 'Invalid UUID', +}) as unknown as z.ZodSchema; + +export const DateTimeSchema = z + .string() + .transform(DateTime.fromISO) as unknown as z.ZodSchema; + +export class SignedObject { + constructor( + private readonly payload: Buffer, + private readonly certificates: Buffer, + private readonly signature: Buffer + ) {} + + getPayload(): Buffer { + return this.payload; + } + + getCertificates(): Buffer { + return this.certificates; + } + + getSignature(): Buffer { + return this.signature; + } + + toJSON(): unknown { + return { + payload: this.payload.toString('base64'), + certificates: this.certificates.toString('base64'), + signature: this.signature.toString('base64'), + }; + } +} + +export class JournalEntry { + constructor( + private readonly id: Uuid, + private readonly objectId: Uuid, + private readonly jurisdiction: JurisdictionCode, + private readonly objectType: string, + private readonly action: JournalEntryAction, + private readonly createdAt: DateTime + ) {} + + getId(): Uuid { + return this.id; + } + + getObjectId(): Uuid { + return this.objectId; + } + + getJurisdiction(): JurisdictionCode { + return this.jurisdiction; + } + + getObjectType(): string { + return this.objectType; + } + + getAction(): JournalEntryAction { + return this.action; + } + + getCreatedAt(): DateTime { + return this.createdAt; + } +} + +export type JurisdictionCode = NewType; + +export const JurisdictionCodeSchema = z + .string() + .refine((s) => /^[a-z]{2}\.[-_a-z0-9]+$/i.test(s), { + message: 'Invalid jurisdiction code', + }) as unknown as z.ZodSchema; + +export type JournalEntryAction = 'create' | 'delete' | string; + +export const JournalEntrySchema: z.ZodSchema<{ + id: Uuid; + objectId: Uuid; + jurisdiction: JurisdictionCode; + objectType: string; + action: string; + createdAt: DateTime; +}> = z.object({ + id: UuidSchema, + objectId: UuidSchema, + jurisdiction: JurisdictionCodeSchema, + objectType: z.string(), + action: z.string(), + createdAt: DateTimeSchema, +}); diff --git a/apps/cacvote-mark/backend/src/server.ts b/apps/cacvote-mark/backend/src/server.ts index 1ecd3ce06b..aca4ede4f3 100644 --- a/apps/cacvote-mark/backend/src/server.ts +++ b/apps/cacvote-mark/backend/src/server.ts @@ -5,6 +5,8 @@ import { Server } from 'http'; import { buildApp } from './app'; import { Auth } from './types/auth'; import { Workspace } from './workspace'; +import { Client } from './cacvote-server/client'; +import { CACVOTE_URL } from './globals'; export interface StartOptions { auth?: Auth; @@ -63,6 +65,14 @@ function getDefaultAuth(): Auth { }; } +function getCacvoteServerClient(): Client { + if (!CACVOTE_URL) { + throw new Error('CACVOTE_URL not set'); + } + + return new Client(CACVOTE_URL); +} + /** * Starts the server with all the default options. */ @@ -72,15 +82,52 @@ export function start({ auth, logger, port, workspace }: StartOptions): Server { workspace, auth: resolvedAuth, }); + const client = getCacvoteServerClient(); async function doCacvoteServerSync() { try { - // TODO: sync with CACVote Server - - await logger.log(LogEventId.ApplicationStartup, 'system', { - message: 'CACVote Server sync succeeded', - disposition: 'success', - }); + const checkResult = await client.checkStatus(); + + if (checkResult.isErr()) { + await logger.log(LogEventId.ApplicationStartup, 'system', { + message: `Failed to check status of CACVote Server: ${checkResult.err()}`, + disposition: 'failure', + }); + } else { + const latestJournalEntry = workspace.store.getLatestJournalEntry(); + + await logger.log(LogEventId.ApplicationStartup, 'system', { + message: `Checking for journal entries from CACVote Server since ${ + latestJournalEntry?.getId() ?? 'the beginning of time' + }`, + }); + + const getEntriesResult = await client.getJournalEntries( + latestJournalEntry?.getId() + ); + + if (getEntriesResult.isErr()) { + await logger.log(LogEventId.ApplicationStartup, 'system', { + message: `Failed to get journal entries from CACVote Server: ${ + getEntriesResult.err().message + }`, + disposition: 'failure', + }); + } else { + const newEntries = getEntriesResult.ok(); + await logger.log(LogEventId.ApplicationStartup, 'system', { + message: `Got ${newEntries.length} journal entries from CACVote Server`, + disposition: 'success', + }); + + workspace.store.addJournalEntries(newEntries); + + await logger.log(LogEventId.ApplicationStartup, 'system', { + message: 'CACVote Server sync succeeded', + disposition: 'success', + }); + } + } } catch (err) { await logger.log(LogEventId.ApplicationStartup, 'system', { message: `Failed to sync with CACVote Server: ${err}`, diff --git a/apps/cacvote-mark/backend/src/store.ts b/apps/cacvote-mark/backend/src/store.ts index 8490f66bde..4cb6ad7386 100644 --- a/apps/cacvote-mark/backend/src/store.ts +++ b/apps/cacvote-mark/backend/src/store.ts @@ -1,7 +1,17 @@ import { Optional } from '@votingworks/basics'; import { Client as DbClient } from '@votingworks/db'; -import { safeParseSystemSettings, SystemSettings } from '@votingworks/types'; +import { + safeParse, + safeParseSystemSettings, + SystemSettings, +} from '@votingworks/types'; import { join } from 'path'; +import { DateTime } from 'luxon'; +import { + JournalEntry, + JurisdictionCodeSchema, + UuidSchema, +} from './cacvote-server/types'; const SchemaPath = join(__dirname, '../schema.sql'); @@ -67,4 +77,59 @@ export class Store { } return safeParseSystemSettings(result.data).unsafeUnwrap(); } + + getLatestJournalEntry(): Optional { + const result = this.client.one( + ` + select id, object_id, jurisdiction, object_type, action, created_at + from journal_entries + order by created_at desc + limit 1` + ) as Optional<{ + id: string; + object_id: string; + jurisdiction: string; + object_type: string; + action: string; + created_at: string; + }>; + + return result + ? new JournalEntry( + safeParse(UuidSchema, result.id).assertOk('assuming valid UUID'), + safeParse(UuidSchema, result.object_id).assertOk( + 'assuming valid UUID' + ), + safeParse(JurisdictionCodeSchema, result.jurisdiction).assertOk( + 'assuming valid jurisdiction code' + ), + result.object_type, + result.action, + DateTime.fromSQL(result.created_at) + ) + : undefined; + } + + /** + * Adds journal entries to the store. + */ + addJournalEntries(entries: JournalEntry[]): void { + this.client.transaction(() => { + const stmt = this.client.prepare( + `insert into journal_entries (id, object_id, jurisdiction, object_type, action, created_at) + values (?, ?, ?, ?, ?, ?)` + ); + + for (const entry of entries) { + stmt.run( + entry.getId(), + entry.getObjectId(), + entry.getJurisdiction(), + entry.getObjectType(), + entry.getAction(), + entry.getCreatedAt().toSQL() + ); + } + }); + } } diff --git a/libs/db/src/client.test.ts b/libs/db/src/client.test.ts index 87edc132e3..4b06cff6c7 100644 --- a/libs/db/src/client.test.ts +++ b/libs/db/src/client.test.ts @@ -122,6 +122,25 @@ test('read/write', () => { ); }); +test('prepare', () => { + const client = Client.memoryClient(); + + client.exec( + 'create table if not exists muppets (name varchar(255) unique not null)' + ); + + const insertStatement = client.prepare( + 'insert into muppets (name) values (?)' + ); + insertStatement.run('Kermit'); + insertStatement.run('Fozzie'); + + expect(client.all('select * from muppets')).toEqual([ + { name: 'Kermit' }, + { name: 'Fozzie' }, + ]); +}); + test('transactions', async () => { const client = Client.memoryClient(); diff --git a/libs/db/src/client.ts b/libs/db/src/client.ts index e5fb9c0486..78771957a4 100644 --- a/libs/db/src/client.ts +++ b/libs/db/src/client.ts @@ -207,6 +207,23 @@ export class Client { db.exec(sql); } + /** + * Prepares a statement for execution. + * + * @example + * + * const stmt = client.prepare('select * from muppets where name = ?'); + * + * for (const muppet of ['Kermit', 'Fozzie', 'Gonzo']) { + * console.log(stmt.get(muppet)); + * } + */ + prepare(sql: string): Database.Statement { + queryDebug('prepare %s', sql); + const db = this.getDatabase(); + return db.prepare(sql); + } + /** * Runs `sql` to fetch a list of rows. * diff --git a/libs/grout/package.json b/libs/grout/package.json index 6056932315..1365495418 100644 --- a/libs/grout/package.json +++ b/libs/grout/package.json @@ -32,7 +32,7 @@ }, "dependencies": { "@votingworks/basics": "workspace:*", - "cross-fetch": "^3.1.5", + "cross-fetch": "^4.0.0", "debug": "4.3.4", "luxon": "^3.0.0" }, diff --git a/libs/types-rs/src/cacvote/mod.rs b/libs/types-rs/src/cacvote/mod.rs index 6af22213bb..0707d2983c 100644 --- a/libs/types-rs/src/cacvote/mod.rs +++ b/libs/types-rs/src/cacvote/mod.rs @@ -63,6 +63,7 @@ impl sqlx::Type for JurisdictionCode { } #[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] pub struct SignedObject { #[serde(with = "Base64Standard")] pub payload: Vec, @@ -142,6 +143,7 @@ impl SignedObject { } #[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] pub struct Payload { pub object_type: String, #[serde(with = "Base64Standard")] @@ -149,12 +151,14 @@ pub struct Payload { } #[derive(Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] pub struct JournalEntry { pub id: Uuid, pub object_id: Uuid, pub jurisdiction: JurisdictionCode, pub object_type: String, pub action: JournalEntryAction, + #[serde(with = "time::serde::iso8601")] pub created_at: time::OffsetDateTime, } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6fa1fd8502..e6b708a50d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -164,8 +164,8 @@ importers: specifier: workspace:* version: link:../../../libs/utils cross-fetch: - specifier: ^3.1.5 - version: 3.1.5 + specifier: ^4.0.0 + version: 4.0.0 debug: specifier: 4.3.4 version: 4.3.4(supports-color@5.5.0) @@ -1392,8 +1392,8 @@ importers: specifier: workspace:* version: link:../basics cross-fetch: - specifier: ^3.1.5 - version: 3.1.5 + specifier: ^4.0.0 + version: 4.0.0 debug: specifier: 4.3.4 version: 4.3.4(supports-color@5.5.0) @@ -10891,6 +10891,7 @@ packages: node-fetch: 2.6.7 transitivePeerDependencies: - encoding + dev: true /cross-fetch@4.0.0: resolution: {integrity: sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==} diff --git a/vxsuite.code-workspace b/vxsuite.code-workspace index e4b1454343..e5b3602625 100644 --- a/vxsuite.code-workspace +++ b/vxsuite.code-workspace @@ -123,6 +123,12 @@ { "path": "script", "name": "script" + }, + { + "path": "services/cacvote-server" + }, + { + "path": "." } ], "settings": {