Files
decision/web/src/lib/receipt.ts
T
YvvandClaude Opus 5.5 51c9a4771d v3 : vote par la chaîne, corrections de la relecture de 74f96be (interface)
- rien n'est parti (nœud muet avant la signature, refus, fenêtre fermée avant) :
  la page lâche le bulletin ; signé, ou montré en code Ğecko, il est gardé et
  le serveur le confirme sans la page ; le refus de la chaîne se dit (« chain ») ;
- le reçu : téléchargeable depuis la fenêtre de vote, et la page « Tes
  bulletins » (/bulletins) qui garde l'historique et vérifie un reçu dans le
  navigateur, sans le serveur (lib/receipt.ts, mêmes vecteurs que le moteur) ;
- le panier ne dit plus « Tes votes comptent dès l'envoi » quand le vote est fermé ;
- @noble/hashes déclaré (utilisé par e2e/chain-mock.mjs et le reçu) ;
- chaîne simulée : finalité, pages, filtres, nœud qui tombe ; contrôles C32, C33.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:11:54 +01:00

91 lines
4.0 KiB
TypeScript

// The receipt of a vote on the Ğ1 chain (ADR 0008): the ballot itself — its
// lines and its salt — as the person who asked for it downloads it
// (`GET /api/ballots/{id}/receipt`). Checked here, in the browser, without asking
// the server anything: the canonical text, its blake2b-256 seal, and the comment
// `ld1:` + seal that the chain carries. The same rules as the engine
// (crates/ld-engine/src/ballot.rs, `canonical` and `seal`).
import { blake2b } from '@noble/hashes/blake2.js';
export type ReceiptChoice = 'for' | 'against' | 'none';
export interface ReceiptLine {
proposalId: string;
revision: number;
choice: ReceiptChoice;
/** For reading only: the article and the title of the version voted. */
article?: string;
title?: string | null;
}
export interface Sealed {
canonical: string;
digest: string;
comment: string;
/** What the indexer finds the comment by: `0x` + blake2b-256 of the comment. */
remarkHash: string;
}
export type ReceiptCheck =
| { kind: 'unreadable' }
| { kind: 'ok'; comment: string; remarkHash: string; block: number | null; lines: ReceiptLine[] }
| { kind: 'mismatch'; comment: string; computed: string };
const CHOICES: readonly string[] = ['for', 'against', 'none'];
/** Printable ASCII without spaces: what reads back, one field per line. */
const PLAIN = /^[\x21-\x7e]+$/;
const SALT = /^[0-9a-f]{32}$/;
const hex = (bytes: Uint8Array) => Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
export function blake2_256(text: string): string {
return hex(blake2b(new TextEncoder().encode(text), { dkLen: 32 }));
}
/** The ballot as it is hashed; `null` if it cannot be one. */
export function canonical(doc: string, author: string | null, lines: ReceiptLine[], salt: string): string | null {
if (!PLAIN.test(doc) || (author !== null && !PLAIN.test(author)) || !SALT.test(salt)) return null;
const sorted = [...lines].sort((a, b) => (a.proposalId < b.proposalId ? -1 : a.proposalId > b.proposalId ? 1 : 0));
if (!sorted.length) return null;
for (const [i, l] of sorted.entries()) {
if (!PLAIN.test(l.proposalId) || (i > 0 && sorted[i - 1]?.proposalId === l.proposalId)) return null;
if (!Number.isInteger(l.revision) || l.revision < 0 || l.revision > 0xffff_ffff || !CHOICES.includes(l.choice)) return null;
}
let out = `ld1\ndoc ${doc}\nauthor ${author ?? '-'}\n`;
for (const l of sorted) out += `line ${l.proposalId} ${l.revision} ${l.choice}\n`;
return `${out}salt ${salt}\n`;
}
export function seal(doc: string, author: string | null, lines: ReceiptLine[], salt: string): Sealed | null {
const text = canonical(doc, author, lines, salt);
if (text === null) return null;
const digest = blake2_256(text);
const comment = `ld1:${digest}`;
return { canonical: text, digest, comment, remarkHash: `0x${blake2_256(comment)}` };
}
const isLine = (l: unknown): l is ReceiptLine => {
const v = l as Partial<ReceiptLine> | null;
return !!v && typeof v.proposalId === 'string' && typeof v.revision === 'number' && typeof v.choice === 'string';
};
/** Reads a receipt (its text, as downloaded) and recomputes its seal. */
export function checkReceipt(text: string): ReceiptCheck {
let raw: unknown;
try {
raw = JSON.parse(text);
} catch {
return { kind: 'unreadable' };
}
const r = raw as Record<string, unknown> | null;
if (!r || typeof r !== 'object' || Array.isArray(r) || r.format !== 'ld1') return { kind: 'unreadable' };
const { doc, author, lines, salt, comment, block } = r;
if (typeof doc !== 'string' || typeof salt !== 'string' || typeof comment !== 'string') return { kind: 'unreadable' };
if (author !== null && typeof author !== 'string') return { kind: 'unreadable' };
if (!Array.isArray(lines) || !lines.every(isLine)) return { kind: 'unreadable' };
const s = seal(doc, author, lines, salt);
if (!s) return { kind: 'unreadable' };
if (s.comment !== comment) return { kind: 'mismatch', comment, computed: s.comment };
return { kind: 'ok', comment, remarkHash: s.remarkHash, block: typeof block === 'number' ? block : null, lines };
}