import { blocksShowTheirText, deriveBody, type AgreementBlock } from '../FirstSignInGate/agreementCopy'; import { HttpError } from '../../services/HttpService'; export interface SandboxAgreementCopy { version: string; title: string; body: string; blocks?: AgreementBlock[]; bodyHash: string } export type SandboxAgreementAnswer = | { kind: 'ready'; copy: SandboxAgreementCopy } | { kind: 'none' } | { kind: 'unreadable' }; type Get = (url: string, options?: RequestInit) => Promise; const MAX_VERSION_LENGTH = 64; const text = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0; const sha256Hex = async (value: string): Promise => { const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)); return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join(''); }; // Copy that would show other words than its own body, or whose hash is not its body's, is no agreement to accept: // the hash is what the acknowledgement names, so it must be the text on screen. const soundCopy = async (body: any): Promise => { if (!text(body?.version) || body.version.length > MAX_VERSION_LENGTH || !text(body.title) || !text(body.body) || !text(body.bodyHash)) return undefined; if (await sha256Hex(body.body) !== body.bodyHash) return undefined; const copy = { version: body.version, title: body.title, body: body.body, bodyHash: body.bodyHash }; if (body.blocks === undefined) return copy; if (!Array.isArray(body.blocks) || body.blocks.length === 0) return undefined; try { if (deriveBody(body.blocks) !== body.body || !blocksShowTheirText(body.blocks)) return undefined; } catch { return undefined; } return { ...copy, blocks: body.blocks }; }; export const SANDBOX_AGREEMENT_UNAVAILABLE = 'SANDBOX_AGREEMENT_UNAVAILABLE'; export const SANDBOX_AGREEMENT_CHANGED = 'SANDBOX_AGREEMENT_CHANGED'; /** The Sandbox agreement in force on the live stack; none while nothing well formed is published. */ export const readSandboxAgreement = async (get: Get, liveApi: string, signal: AbortSignal): Promise => { try { const copy = await soundCopy(await get(`${liveApi}/sandbox-agreement`, { cache: 'no-store', signal })); return copy ? { kind: 'ready', copy } : { kind: 'none' }; } catch (error) { if (signal.aborted) throw error; if (error instanceof HttpError && (error.body as { code?: unknown } | undefined)?.code === SANDBOX_AGREEMENT_UNAVAILABLE) return { kind: 'none' }; return { kind: 'unreadable' }; } }; export const agreementChanged = (error: unknown): boolean => error instanceof HttpError && error.status === 409 && (error.body as { code?: unknown } | undefined)?.code === SANDBOX_AGREEMENT_CHANGED;