Summary
@anonvote/crypto exports five low-level cryptographic primitives. They are correct and auditable at the function level but they are not an SDK. A
developer who wants to build an integration on top of AnonVote — a custom voting interface, a Stellar-native election tool, a mobile client — has no unified entry point. They must manually understand the composition order of five functions, manage key scoping themselves, serialise payloads correctly, and handle every error case individually. This creates a high integration barrier and a high probability of misuse.
The AnonVoteClient SDK is the public developer interface for the AnonVote ecosystem. It sits above the raw primitives and exposes a small, opinionated API that hides the cryptographic composition details while enforcing the correct usage patterns. It must be impossible to use AnonVoteClient in a way that violates the privacy model — the SDK must make correct usage the only usage.
This is a design problem as much as an implementation problem. The API surface must be minimal enough that a developer can understand it in 10 minutes, typed well enough that TypeScript prevents misuse at compile time, and documented well enough that a developer integrating it for the first time never needs to read the underlying src/crypto.ts source.
What Needs to Be Built
Architecture Decision — New File or New Package
The first decision is where AnonVoteClient lives. Two options:
Option A — Add src/client.ts to the existing js repo and export AnonVoteClient alongside the existing crypto primitives from src/index.ts.
This keeps everything in one package — @anonvote/crypto exports both the primitives and the client. Simple, one package to install.
Option B — Create a separate src/client/ directory with its own entry point, and export it as a separate subpath export @anonvote/crypto/client. This allows tree-shaking — consumers who only want the raw primitives do not import the client code.
The decision must be made and documented in DECISIONS.md before any implementation begins. The recommended approach is Option B — subpath exports are a Node.js 12+ feature, the package.json already has TypeScript configured, and keeping the client in a subpath makes the package surface explicit. Update package.json exports field:
{
"exports": {
".": "./dist/index.js",
"./client": "./dist/client/index.js"
}
}
src/client/types.ts — Client Type Definitions
Define the types that the client API exposes. These are higher-level than the types in src/types.ts — they represent domain concepts, not crypto wire formats:
export interface ClientConfig {
// The per-ballot encryption key as a 64-character hex string.
// Must be generated fresh per ballot using crypto.randomBytes(32).toString('hex').
// Must never be the same key across two ballots.
// Must never be stored in the database alongside encrypted votes.
ballotKey: string
}
export interface ElectionOptions {
title: string
description: string
options: string[] // Array of option labels — minimum 2, maximum 10
startTime: Date
endTime: Date
}
export interface Election {
id: string // UUID generated by createElection
title: string
description: string
options: ElectionOption[]
startTime: Date
endTime: Date
createdAt: Date
status: 'draft' | 'active' | 'closed' | 'finalised'
}
export interface ElectionOption {
id: string // UUID generated per option — used as the optionId in votes
label: string
index: number
}
export interface Ballot {
electionId: string
optionId: string // Must be one of the Election's option IDs
encryptedPayload: EncryptedPayload // Imported from src/types.ts
createdAt: Date
}
export interface VoteReceipt {
electionId: string
tokenHash: string // SHA-256 hash of the voter's token — proof of participation
ballot: Ballot
submittedAt: Date
}
export interface VerificationResult {
confirmed: boolean
electionId: string
checkedAt: Date
}
src/client/index.ts — AnonVoteClient Class
The client is a class that takes a ClientConfig in its constructor and exposes five methods. The constructor must validate the ballotKey immediately on instantiation — throw AnonVoteCryptoError INVALID_KEY if it is not a 64-character hex string. A misconfigured client must fail loudly at construction time, not silently at the first crypto operation.
import { AnonVoteCryptoError } from '../types'
import { encryptVote, decryptVote, generateToken, hashToken } from '../crypto'
import type { ClientConfig, Election, ElectionOptions, Ballot, VoteReceipt, VerificationResult } from './types'
export class AnonVoteClient {
private readonly config: ClientConfig
constructor(config: ClientConfig) {
// Validate ballotKey immediately
// Throw AnonVoteCryptoError INVALID_KEY if not 64-char hex
}
createElection(options: ElectionOptions): Election
Creates and returns an Election object. This is a pure client-side operation — it does not make any network calls. It generates a UUID for the election ID and a UUID for each option ID. Validates that options.options has between 2 and 10 entries. Validates that startTime is before endTime. Validates that endTime is in the future. Throws AnonVoteCryptoError INVALID_ELECTION with a descriptive message for any validation failure.
The option IDs generated here are what get passed to castVote — they are the values that end up as encrypted payloads in the database. The client must ensure they are UUIDs, not option labels, so no option text ever reaches the encryption layer.
castVote(election: Election, optionId: string): Ballot
Validates that optionId exists in election.options — throw AnonVoteCryptoError INVALID_OPTION if not. Validates that the election status is active and that new Date() is between startTime and endTime — throw AnonVoteCryptoError ELECTION_NOT_ACTIVE if not.
Calls encryptVote(optionId, this.config.ballotKey) from src/crypto.ts. Returns a Ballot object with the electionId, optionId, and encryptedPayload. The optionId in the returned Ballot is included so the voter can verify what they voted for before submission — it is not sent
to the server.
The method must never log the optionId. The encryptedPayload must be the only thing that leaves this method in a form suitable for server submission.
verifyVote(ballot: Ballot): VerificationResult
Verifies that a ballot produced by castVote can be successfully decrypted and that the decrypted optionId matches the ballot's optionId. This is a local verification step — it does not contact the server.
Calls decryptVote(ballot.encryptedPayload, this.config.ballotKey). Compares the result to ballot.optionId. Returns a VerificationResult with confirmed: true if they match, confirmed: false if they do not. If decryptVote throws, propagate the error — do not catch and return
confirmed: false. A decryption error means something is wrong with the payload or key, not just an option mismatch.
serialize(ballot: Ballot): string
Converts a Ballot to a JSON string suitable for sending to the AnonVote API or storing for later submission. The serialised form must be a stable, deterministic JSON string — use JSON.stringify with keys sorted alphabetically so the same ballot always produces the same string.
The serialised string must not include the raw optionId from the Ballot object — only electionId and encryptedPayload are serialised. The optionId stays local. Add a @security JSDoc note explaining why.
deserialize(serialized: string): Ballot
Parses a JSON string produced by serialize back into a Ballot object. Validates the structure — electionId must be a non-empty string, encryptedPayload must have ciphertext, iv, and authTag fields. Throw AnonVoteCryptoError INVALID_SERIALIZED_BALLOT if validation fails.
The deserialized Ballot has no optionId — that field is undefined after deserialization by design, because the option ID was never serialised.
JSDoc Documentation
Every public method must have a complete JSDoc block:
@description — what the method does and why
@param — every parameter with type and constraints
@returns — return type and what each field means
@throws — every AnonVoteCryptoError code the method can throw and the condition that triggers it
@example — a minimal working code example
@security — for any method where there is a privacy-relevant constraint the caller must know about
tests/client.test.ts — Full Test Suite
createElection tests
createElection returns an Election with unique IDs for the election and each option
createElection throws INVALID_ELECTION for fewer than 2 options
createElection throws INVALID_ELECTION for more than 10 options
createElection throws INVALID_ELECTION when endTime is before startTime
createElection throws INVALID_ELECTION when endTime is in the past
createElection option IDs are UUIDs — not the option label text
castVote tests
castVote returns a Ballot with an EncryptedPayload
castVote throws INVALID_OPTION for an optionId not in the election
castVote throws ELECTION_NOT_ACTIVE for a closed election
castVote two calls with the same optionId produce different EncryptedPayloads
castVote Ballot contains optionId locally but serialize omits it
verifyVote tests
verifyVote returns confirmed: true for a valid ballot
verifyVote propagates decryption error — does not catch and return false
verifyVote roundtrip — castVote then verifyVote always returns confirmed: true
serialize and deserialize tests
serialize produces a stable deterministic JSON string
serialize omits optionId from the output
deserialize reconstructs a valid Ballot from serialized output
deserialize throws INVALID_SERIALIZED_BALLOT for missing ciphertext field
deserialize Ballot has no optionId field after deserialization
serialize then deserialize then verifyVote still returns confirmed: true
constructor tests
AnonVoteClient throws INVALID_KEY for a 32-character ballotKey
AnonVoteClient throws INVALID_KEY for a non-hex ballotKey
AnonVoteClient instantiates successfully with a valid 64-character hex key
type export tests
Election type is exported and assignable
Ballot type is exported and assignable
VoteReceipt type is exported and assignable
ClientConfig type is exported and assignable
Relevant Files
New files to create:
src/client/types.ts
src/client/index.ts
tests/client.test.ts
Existing files to modify:
package.json — add exports field with subpath for ./client
tsconfig.json — ensure src/client/ is included in compilation
DECISIONS.md — document the subpath export architecture decision
README.md — add AnonVoteClient section with installation and quick start
Acceptance Criteria
Note for Contributors
The serialize method's decision to omit optionId is a deliberate security choice, not an oversight. The option ID is what the voter voted for. Once the ballot is serialised for server submission, the option ID must not leave the client in plaintext — only the encrypted payload goes to the server. A PR that includes optionId in the serialised output will be closed.
The verifyVote method must propagate decryption errors. Do not write a try/catch that catches the decryption error and returns { confirmed: false }. A decryption error means the payload is corrupted or the key is wrong — these are different failure modes from "option ID does not match" and they must surface as exceptions so the caller can handle them correctly.
Summary
@anonvote/cryptoexports five low-level cryptographic primitives. They are correct and auditable at the function level but they are not an SDK. Adeveloper who wants to build an integration on top of AnonVote — a custom voting interface, a Stellar-native election tool, a mobile client — has no unified entry point. They must manually understand the composition order of five functions, manage key scoping themselves, serialise payloads correctly, and handle every error case individually. This creates a high integration barrier and a high probability of misuse.
The
AnonVoteClientSDK is the public developer interface for the AnonVote ecosystem. It sits above the raw primitives and exposes a small, opinionated API that hides the cryptographic composition details while enforcing the correct usage patterns. It must be impossible to useAnonVoteClientin a way that violates the privacy model — the SDK must make correct usage the only usage.This is a design problem as much as an implementation problem. The API surface must be minimal enough that a developer can understand it in 10 minutes, typed well enough that TypeScript prevents misuse at compile time, and documented well enough that a developer integrating it for the first time never needs to read the underlying
src/crypto.tssource.What Needs to Be Built
Architecture Decision — New File or New Package
The first decision is where
AnonVoteClientlives. Two options:Option A — Add
src/client.tsto the existingjsrepo and exportAnonVoteClientalongside the existing crypto primitives fromsrc/index.ts.This keeps everything in one package —
@anonvote/cryptoexports both the primitives and the client. Simple, one package to install.Option B — Create a separate
src/client/directory with its own entry point, and export it as a separate subpath export@anonvote/crypto/client. This allows tree-shaking — consumers who only want the raw primitives do not import the client code.The decision must be made and documented in
DECISIONS.mdbefore any implementation begins. The recommended approach is Option B — subpath exports are a Node.js 12+ feature, thepackage.jsonalready has TypeScript configured, and keeping the client in a subpath makes the package surface explicit. Updatepackage.jsonexportsfield:{ "exports": { ".": "./dist/index.js", "./client": "./dist/client/index.js" } }src/client/types.ts— Client Type DefinitionsDefine the types that the client API exposes. These are higher-level than the types in
src/types.ts— they represent domain concepts, not crypto wire formats:src/client/index.ts— AnonVoteClient ClassThe client is a class that takes a
ClientConfigin its constructor and exposes five methods. The constructor must validate theballotKeyimmediately on instantiation — throwAnonVoteCryptoError INVALID_KEYif it is not a 64-character hex string. A misconfigured client must fail loudly at construction time, not silently at the first crypto operation.createElection(options: ElectionOptions): ElectionCreates and returns an
Electionobject. This is a pure client-side operation — it does not make any network calls. It generates a UUID for the election ID and a UUID for each option ID. Validates thatoptions.optionshas between 2 and 10 entries. Validates thatstartTimeis beforeendTime. Validates thatendTimeis in the future. ThrowsAnonVoteCryptoError INVALID_ELECTIONwith a descriptive message for any validation failure.The option IDs generated here are what get passed to
castVote— they are the values that end up as encrypted payloads in the database. The client must ensure they are UUIDs, not option labels, so no option text ever reaches the encryption layer.castVote(election: Election, optionId: string): BallotValidates that
optionIdexists inelection.options— throwAnonVoteCryptoError INVALID_OPTIONif not. Validates that the electionstatusisactiveand thatnew Date()is betweenstartTimeandendTime— throwAnonVoteCryptoError ELECTION_NOT_ACTIVEif not.Calls
encryptVote(optionId, this.config.ballotKey)fromsrc/crypto.ts. Returns aBallotobject with theelectionId,optionId, andencryptedPayload. TheoptionIdin the returnedBallotis included so the voter can verify what they voted for before submission — it is not sentto the server.
The method must never log the
optionId. TheencryptedPayloadmust be the only thing that leaves this method in a form suitable for server submission.verifyVote(ballot: Ballot): VerificationResultVerifies that a ballot produced by
castVotecan be successfully decrypted and that the decryptedoptionIdmatches the ballot'soptionId. This is a local verification step — it does not contact the server.Calls
decryptVote(ballot.encryptedPayload, this.config.ballotKey). Compares the result toballot.optionId. Returns aVerificationResultwithconfirmed: trueif they match,confirmed: falseif they do not. IfdecryptVotethrows, propagate the error — do not catch and returnconfirmed: false. A decryption error means something is wrong with the payload or key, not just an option mismatch.serialize(ballot: Ballot): stringConverts a
Ballotto a JSON string suitable for sending to the AnonVote API or storing for later submission. The serialised form must be a stable, deterministic JSON string — useJSON.stringifywith keys sorted alphabetically so the same ballot always produces the same string.The serialised string must not include the raw
optionIdfrom theBallotobject — onlyelectionIdandencryptedPayloadare serialised. TheoptionIdstays local. Add a@securityJSDoc note explaining why.deserialize(serialized: string): BallotParses a JSON string produced by
serializeback into aBallotobject. Validates the structure —electionIdmust be a non-empty string,encryptedPayloadmust haveciphertext,iv, andauthTagfields. ThrowAnonVoteCryptoError INVALID_SERIALIZED_BALLOTif validation fails.The deserialized
Ballothas nooptionId— that field isundefinedafter deserialization by design, because the option ID was never serialised.JSDoc Documentation
Every public method must have a complete JSDoc block:
@description— what the method does and why@param— every parameter with type and constraints@returns— return type and what each field means@throws— everyAnonVoteCryptoErrorcode the method can throw and the condition that triggers it@example— a minimal working code example@security— for any method where there is a privacy-relevant constraint the caller must know abouttests/client.test.ts— Full Test SuitecreateElection tests
createElection returns an Election with unique IDs for the election and each optioncreateElection throws INVALID_ELECTION for fewer than 2 optionscreateElection throws INVALID_ELECTION for more than 10 optionscreateElection throws INVALID_ELECTION when endTime is before startTimecreateElection throws INVALID_ELECTION when endTime is in the pastcreateElection option IDs are UUIDs — not the option label textcastVote tests
castVote returns a Ballot with an EncryptedPayloadcastVote throws INVALID_OPTION for an optionId not in the electioncastVote throws ELECTION_NOT_ACTIVE for a closed electioncastVote two calls with the same optionId produce different EncryptedPayloadscastVote Ballot contains optionId locally but serialize omits itverifyVote tests
verifyVote returns confirmed: true for a valid ballotverifyVote propagates decryption error — does not catch and return falseverifyVote roundtrip — castVote then verifyVote always returns confirmed: trueserialize and deserialize tests
serialize produces a stable deterministic JSON stringserialize omits optionId from the outputdeserialize reconstructs a valid Ballot from serialized outputdeserialize throws INVALID_SERIALIZED_BALLOT for missing ciphertext fielddeserialize Ballot has no optionId field after deserializationserialize then deserialize then verifyVote still returns confirmed: trueconstructor tests
AnonVoteClient throws INVALID_KEY for a 32-character ballotKeyAnonVoteClient throws INVALID_KEY for a non-hex ballotKeyAnonVoteClient instantiates successfully with a valid 64-character hex keytype export tests
Election type is exported and assignableBallot type is exported and assignableVoteReceipt type is exported and assignableClientConfig type is exported and assignableRelevant Files
New files to create:
src/client/types.tssrc/client/index.tstests/client.test.tsExisting files to modify:
package.json— addexportsfield with subpath for./clienttsconfig.json— ensuresrc/client/is included in compilationDECISIONS.md— document the subpath export architecture decisionREADME.md— addAnonVoteClientsection with installation and quick startAcceptance Criteria
DECISIONS.mddocuments the subpath export architecture before any implementation beginsAnonVoteClientexported from@anonvote/crypto/clientINVALID_KEYfor any invalidballotKeyat instantiation timecastVotenever logsoptionId— add a test that spies onconsoleand asserts nothing is logged during acastVotecallserializeomitsoptionId— confirmed by testverifyVotepropagates decryption errors — does not catch and returnconfirmed: falsesrc/client/types.tsnpm run buildproducesdist/client/index.jsanddist/client/index.d.ts@anonvote/crypto/clientresolves correctly in a consuming TypeScript project — test this with a minimaltest-consumer/directoryNote for Contributors
The
serializemethod's decision to omitoptionIdis a deliberate security choice, not an oversight. The option ID is what the voter voted for. Once the ballot is serialised for server submission, the option ID must not leave the client in plaintext — only the encrypted payload goes to the server. A PR that includesoptionIdin the serialised output will be closed.The
verifyVotemethod must propagate decryption errors. Do not write a try/catch that catches the decryption error and returns{ confirmed: false }. A decryption error means the payload is corrupted or the key is wrong — these are different failure modes from "option ID does not match" and they must surface as exceptions so the caller can handle them correctly.