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>
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
// 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user