From 2c2a28dc9fe96496e9e29a865e2bef1ddc19133e Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 6 Oct 2026 18:37:06 +0200 Subject: [PATCH] The public note on /inspect, plain errors, and no reload on the first opening /inspect shows the public note under the verdict, as text of the creator that nobody checked, with the warning of Go's showNote. The pages no longer show the message of an unexpected exception: unexpectedProblem says in Spanish what to do, and logs the exception. vite.config.ts pre-bundles the dependencies the pages load on demand, so that the dev server does not reload the page on the first opening and break the import in flight. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 ++++ README.md | 1 + src/lib/components/InspectionReport.svelte | 44 ++++++++++++++++++++++ src/lib/components/OpenPanel.svelte | 4 +- src/lib/dependencies.test.ts | 10 +++++ src/lib/inspector/format.test.ts | 30 ++++++++++++++- src/lib/inspector/format.ts | 26 +++++++++++++ src/lib/inspector/report.test.ts | 10 +++++ src/lib/inspector/report.ts | 8 ++++ src/routes/create/+page.svelte | 10 ++--- src/routes/inspect/+page.svelte | 4 +- vite.config.ts | 18 +++++++++ 12 files changed, 162 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aaf495d..9642fbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ Cambios notables de la librería TypeScript y de la página. El proyecto usa ver ## Especificación 0.10, en la rama `v0.10` — sin versión +### La nota pública en `/inspect` (06-10-2026) + +- `/inspect` muestra la nota pública justo debajo del veredicto: el texto del creador, en la letra serif de la página y sobre el fondo de aviso, con dos líneas que dicen que nadie la ha comprobado, que la lee cualquiera que tenga el fichero y que antes de la fecha nadie puede comprobar quién creó la cápsula ni si va firmada, como `showNote` de la CLI de Go. Una nota que no cumple las reglas de texto no se muestra, y la página lo dice. +- `buildReport` lleva la nota en `Report.note`, con su prueba. Comprobado en el navegador con el fixture `format3_note`. +- **Errores que no se esperaban.** Las páginas ya no enseñan el mensaje de una excepción, que podía venir en inglés y con una URL interna, como «Failed to fetch dynamically imported module: http://localhost:5188/src/lib/inspector/opener.ts». `unexpectedProblem` de `format.ts` lo dice en español y con lo que hay que hacer: recargar si no llegó una parte de la página, volver a elegir un fichero que el navegador ya no deja leer, liberar espacio, o, en otro caso, recargar y usar la herramienta de línea de órdenes. La excepción va a la consola. Lo usan la apertura, la lectura de una cápsula en `/inspect` y la creación en `/create`. +- **El servidor de desarrollo ya no recarga la página al abrir.** `vite.config.ts` hace que Vite prepare desde el arranque las dependencias que las páginas cargan bajo demanda (`optimizeDeps.include`). Sin eso, la primera apertura le hacía preparar dos módulos de noble y recargar la página, y la carga en curso fallaba con ese mensaje. `dependencies.test.ts` comprueba que la lista está completa. La versión compilada no tenía el problema. + ### NAT64, del borrador v0.13 (06-10-2026) - `testdata` se sincroniza con `datekeys-go` en `a83b44d`, el borrador v0.13, sin aprobar: añade `vectors/resolved_ip.json`, y ningún otro fichero cambia. `SPEC_VERSION` sigue en `0.12`. diff --git a/README.md b/README.md index 8d155a2..f8d9421 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,7 @@ Sitio SvelteKit estático (`@sveltejs/adapter-static`, `strict`): las tres pági - cada paso con su número, su nombre de la CLI, `superado` o el código normativo, y el detalle (los caracteres invisibles o de control se escriben como `\uXXXX`); - el veredicto, `capsule_id`, la DateKey compacta y decodificada (red y ronda), el perfil fijado, la fecha de apertura en UTC y en la hora local del navegador, `access_policy`, los campos del prelude con el formato de la cápsula (con un aviso en el veredicto si es el 1, que no oculta el número de credenciales ni la longitud exacta del contenido, §55.2), los argumentos del stanza `tlock` frente al perfil fijado y el número y tipo de stanzas de OUTER_TIME_AGE y PAYLOAD_AGE; +- la nota pública, justo debajo del veredicto, como texto del creador sin comprobar, en la letra serif de la página y sobre el fondo de aviso, con el aviso de que antes de la fecha nadie puede comprobar quién creó la cápsula ni si va firmada, como `showNote` de la CLI de Go; si la cabecera lleva una nota que no cumple las reglas de texto, solo dice que no se muestra (§24.1); - las extensiones de PUBLIC_HEADER según el contrato del plan §8: id (entre comillas y escapado si tiene caracteres no imprimibles), versión, crítica o no, conocida o no, longitud y hex (plegado si pasa de 64 bytes); texto si los bytes son UTF-8 imprimible; vista CBOR con `walk` si son un ítem del perfil de §58, marcada "informativo, no validado por el protocolo"; y el aviso de que son públicas, de que nada las vincula al resto de la cápsula antes del paso 15 y de que ni entonces prueban autoría (§55.1); - **Copiar JSON**, que copia exactamente la salida de `datekeys inspect -json` (`cliJSON`: el `json.Encoder` de Go con sangría de dos espacios, `<`, `>`, `&`, U+2028 y U+2029 escapados y salto de línea final). `file` es el nombre del fichero. diff --git a/src/lib/components/InspectionReport.svelte b/src/lib/components/InspectionReport.svelte index cd93bc1..5f0cd61 100644 --- a/src/lib/components/InspectionReport.svelte +++ b/src/lib/components/InspectionReport.svelte @@ -141,6 +141,18 @@ + {#if report.note && 'text' in report.note} +
+
{report.note.text}
+
+

Nota pública del creador, sin comprobar: la puede leer cualquiera que tenga el fichero.

+

Nadie puede comprobar antes de la fecha quién creó la cápsula ni si va firmada.

+
+
+ {:else if report.note} +

La cápsula lleva una nota pública que no cumple las reglas de texto: no se muestra.

+ {/if} +

Pasos 1 a 8

@@ -425,6 +437,38 @@ background: var(--caution-bg); font-size: var(--t-small); } + /* The note is the creator's voice, not the page's: the serif of the + letter, on the caution of what nobody has checked. */ + .note { + margin: -1.25rem 0 0; + padding: 1rem 1.25rem; + border-left: 4px solid var(--orange); + border-radius: 0 var(--radius) var(--radius) 0; + background: var(--caution-bg); + } + .note blockquote { + margin: 0; + max-width: var(--measure); + font-family: var(--serif); + font-size: var(--t-lead); + line-height: 1.5; + overflow-wrap: anywhere; + } + .note figcaption { + margin-top: 0.6rem; + font-size: var(--t-small); + color: var(--orange-text); + } + .note figcaption p { + margin: 0; + } + .note-unusable { + margin: -1.25rem 0 0; + padding: 0.5rem 0.75rem; + border-radius: var(--radius); + background: var(--caution-bg); + font-size: var(--t-small); + } .state-word { font-size: var(--t-h2); font-weight: 700; diff --git a/src/lib/components/OpenPanel.svelte b/src/lib/components/OpenPanel.svelte index fa1ba7f..1c2c3b5 100644 --- a/src/lib/components/OpenPanel.svelte +++ b/src/lib/components/OpenPanel.svelte @@ -26,7 +26,7 @@ import { onDestroy, tick } from 'svelte'; import { toHex } from '$lib/dkc/index.ts'; import type { Fixture } from '$lib/inspector/fixtures.ts'; - import { errorGloss, escapeInvisible, formatByteCount, formatDateTime, formatInteger } from '$lib/inspector/format.ts'; + import { errorGloss, escapeInvisible, formatByteCount, formatDateTime, formatInteger, unexpectedProblem } from '$lib/inspector/format.ts'; import type { OpenedFiles } from '$lib/inspector/opener.ts'; import { buildOpenReport, contentExtension, type OpenReport, plaintextFileName, plaintextPreview, SHOWN_TEXT } from '$lib/inspector/opening.ts'; import { fetchRelease } from '$lib/inspector/drand.ts'; @@ -332,7 +332,7 @@ await tick(); document.getElementById('open-result-title')?.focus(); } catch (err) { - if (!stale()) await fail(`No se pudo abrir: ${escapeInvisible(err instanceof Error ? err.message : String(err))}`); + if (!stale()) await fail(unexpectedProblem('abrir la cápsula', err)); } finally { // A file that did not become `temp`: a failure, or a stale opening. if (t !== undefined) { diff --git a/src/lib/dependencies.test.ts b/src/lib/dependencies.test.ts index 183e019..6b5e3d0 100644 --- a/src/lib/dependencies.test.ts +++ b/src/lib/dependencies.test.ts @@ -308,6 +308,16 @@ describe('runtime dependencies', () => { } }); + it('vite.config.ts pre-bundles every runtime dependency that the pages import, so that the dev server never reloads during a lazy import', async () => { + const { default: config } = (await import(pathToFileURL(join(ROOT, 'vite.config.ts')).href)) as { default: { optimizeDeps?: { include?: string[] } } }; + const used = new Set(); + for (const s of sources()) { + if (/\.test\.ts$/.test(s.name) || s.name.startsWith(TESTING_DIR)) continue; + for (const spec of specifiers(s.text)) if (spec.startsWith('@noble/') || spec === 'age-encryption') used.add(spec); + } + expect([...(config.optimizeDeps?.include ?? [])].sort()).toEqual([...used].sort()); + }); + it('src/ resolves noble to the root copies, 2.4.0', async () => { // No node_modules directory inside src/ can shadow the root ones. expect(walk(join(ROOT, 'src'), () => true).filter((p) => rel(p).split('/').includes('node_modules'))).toEqual([]); diff --git a/src/lib/inspector/format.test.ts b/src/lib/inspector/format.test.ts index 7fc9c9a..4de515c 100644 --- a/src/lib/inspector/format.test.ts +++ b/src/lib/inspector/format.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { ERROR_CODES, type InspectView } from '../dkc/index.ts'; import { cliJSON, @@ -20,6 +20,7 @@ import { stepGloss, stepName, viewerTimeZone, + unexpectedProblem, } from './format.ts'; const te = new TextEncoder(); @@ -194,3 +195,30 @@ describe('escapeInvisible of a checked text', () => { expect(escapeInvisible(`a${zwj}b`)).toBe(`a${String.fromCharCode(92)}u200db`); }); }); + +describe('unexpectedProblem', () => { + it('says what to do in Spanish, never the message of the exception, which goes to the console', () => { + const log = vi.spyOn(console, 'error').mockImplementation(() => undefined); + try { + const lazy = new TypeError('Failed to fetch dynamically imported module: http://localhost:5188/src/lib/inspector/opener.ts'); + expect(unexpectedProblem('abrir la cápsula', lazy)).toBe( + 'No se pudo abrir la cápsula: la página no consiguió cargar una de sus partes. Recarga la página y vuelve a intentarlo.', + ); + expect(log).toHaveBeenCalledWith(lazy); + expect(unexpectedProblem('x', new TypeError('Importing a module script failed.'))).toMatch(/cargar una de sus partes/); + expect(unexpectedProblem('x', new TypeError('error loading dynamically imported module'))).toMatch(/cargar una de sus partes/); + expect(unexpectedProblem('leer a.dkc', new DOMException('The file could not be read', 'NotReadableError'))).toBe( + 'No se pudo leer a.dkc: el navegador ya no deja leer el fichero, quizá porque cambió o se movió. Vuelve a elegirlo.', + ); + expect(unexpectedProblem('x', new DOMException('gone', 'NotFoundError'))).toMatch(/Vuelve a elegirlo/); + expect(unexpectedProblem('crear la cápsula', new DOMException('full', 'QuotaExceededError'))).toMatch(/no deja más espacio/); + const other = unexpectedProblem('crear la cápsula', new Error('capsule: internal error: ‮ secret')); + expect(other).toBe( + 'No se pudo crear la cápsula por un fallo de la página. Recarga la página y vuelve a intentarlo; si vuelve a pasar, usa la herramienta de línea de órdenes de DateKeys.', + ); + expect(unexpectedProblem('x', 'a string')).toMatch(/por un fallo de la página/); + } finally { + log.mockRestore(); + } + }); +}); diff --git a/src/lib/inspector/format.ts b/src/lib/inspector/format.ts index c03b357..705b29b 100644 --- a/src/lib/inspector/format.ts +++ b/src/lib/inspector/format.ts @@ -244,6 +244,32 @@ export function escapeInvisible(s: string, joiners = false): string { return out; } +/** + * What the page says when something it did not expect fails while it was + * trying to `what` ("abrir la cápsula"): a sentence in plain Spanish that + * says what to do, never the message of the exception, which may be in + * English, name an internal URL or carry the text of a file. The exception + * itself goes to the console, for whoever debugs the page. + */ +export function unexpectedProblem(what: string, err: unknown): string { + console.error(err); + const name = err instanceof Error ? err.name : ''; + const message = err instanceof Error ? err.message : ''; + // A part of the page that loads on demand did not arrive: the server went + // away, the connection dropped, or a new version replaced the old files. + // Each browser words it its own way. + if (/dynamically imported module|Importing a module script failed|Failed to fetch|NetworkError|Load failed/i.test(message)) { + return `No se pudo ${what}: la página no consiguió cargar una de sus partes. Recarga la página y vuelve a intentarlo.`; + } + if (name === 'NotReadableError' || name === 'NotFoundError') { + return `No se pudo ${what}: el navegador ya no deja leer el fichero, quizá porque cambió o se movió. Vuelve a elegirlo.`; + } + if (name === 'QuotaExceededError') { + return `No se pudo ${what}: el navegador no deja más espacio a esta página. Libera espacio y vuelve a intentarlo.`; + } + return `No se pudo ${what} por un fallo de la página. Recarga la página y vuelve a intentarlo; si vuelve a pasar, usa la herramienta de línea de órdenes de DateKeys.`; +} + // --------------------------------------------------------------------------- // Numbers and dates diff --git a/src/lib/inspector/report.test.ts b/src/lib/inspector/report.test.ts index a6f97a0..30a2504 100644 --- a/src/lib/inspector/report.test.ts +++ b/src/lib/inspector/report.test.ts @@ -72,6 +72,16 @@ describe('buildReport', () => { } }); + it('carries the public note, or that a note is not shown', () => { + const note = readBytes('fixtures/format3_note.dkc'); + const base = inspectWith(note, registry); + expect(report(note).note).toBeUndefined(); // inspectWith does not read the note: inspect does + const r = (inspection: typeof base): Report => buildReport({ fileName: 'n.dkc', bytes: note, size: note.length, inspection }); + expect(r({ ...base, publicNote: 'Cartas del viaje', unusableNote: false }).note).toEqual({ text: 'Cartas del viaje' }); + expect(r({ ...base, unusableNote: true }).note).toEqual({ unusable: true }); + expect(r(base).note).toBeUndefined(); + }); + it('shows header extension data as text, never as CBOR when it is not', () => { const r = report(timeOnly); expect(r.extensions).toEqual([ diff --git a/src/lib/inspector/report.ts b/src/lib/inspector/report.ts index 2dc699a..865b5c5 100644 --- a/src/lib/inspector/report.ts +++ b/src/lib/inspector/report.ts @@ -136,6 +136,12 @@ export interface Report { readonly tlock?: readonly Comparison[]; /** Critical extensions first, then noncritical; undefined until PUBLIC_HEADER decoded. */ readonly extensions?: readonly ExtensionRow[]; + /** + * The public note of the header (spec §24.1): text of the creator that + * nobody has checked, or `unusable` when the header holds a note that + * breaks the rules of text, which is not shown. Undefined without a note. + */ + readonly note?: { readonly text: string } | { readonly unusable: true }; } export interface ReportInput { @@ -230,6 +236,8 @@ export function buildReport(input: ReportInput): Report { ]; } } + if (r.publicNote !== undefined) out.note = { text: r.publicNote }; + else if (r.unusableNote === true) out.note = { unusable: true }; return out; } diff --git a/src/routes/create/+page.svelte b/src/routes/create/+page.svelte index 76079c7..52def5d 100644 --- a/src/routes/create/+page.svelte +++ b/src/routes/create/+page.svelte @@ -32,7 +32,7 @@ } from '$lib/inspector/create-files.ts'; import { quickWords } from '$lib/dkc/wordkey.ts'; import { type CapsulePlan, chooseFiles, type CreateField, downloadName, planCapsule, readRecipients } from '$lib/inspector/create-input.ts'; - import { escapeInvisible, formatByteCount, formatDateTime, formatInteger, formatRelative, viewerTimeZone } from '$lib/inspector/format.ts'; + import { escapeInvisible, formatByteCount, formatDateTime, formatInteger, formatRelative, unexpectedProblem, viewerTimeZone } from '$lib/inspector/format.ts'; import { isTimeZone, localParts, supportedTimeZones, timeZoneList, UTC } from '$lib/inspector/localtime.ts'; import { buildReport, type Report } from '$lib/inspector/report.ts'; import { browserPlatform, CREATE_AREA, createTempFile, freeSpace, OPEN_AREA, removeStaleTempFiles, type TempFile } from '$lib/inspector/tempfile.ts'; @@ -453,7 +453,7 @@ notice = announcement = err instanceof TooManyFiles ? tooMany() - : `No se pudo leer lo que has soltado: ${escapeInvisible(err instanceof Error ? err.message : String(err))}`; + : unexpectedProblem('leer lo que has soltado', err); } finally { reading = false; } @@ -470,7 +470,7 @@ try { c = await loadChecker(); } catch (err) { - await fail(`No se pudieron cargar las reglas de las rutas: ${escapeInvisible(err instanceof Error ? err.message : String(err))}`); + await fail(unexpectedProblem('preparar la comprobación de los nombres de los ficheros', err)); return; } if (epoch !== createId || busy || reading) return; @@ -587,7 +587,7 @@ nowMs = Date.now(); await fail(PAST, 'date'); } else { - await fail(`No se pudo crear la cápsula: ${escapeInvisible(err instanceof Error ? err.message : String(err))}`); + await fail(unexpectedProblem('crear la cápsula', err)); } return; } @@ -621,7 +621,7 @@ await tick(); document.getElementById('created-title')?.focus(); } catch (err) { - if (!stale()) await fail(`No se pudo crear la cápsula: ${escapeInvisible(err instanceof Error ? err.message : String(err))}`); + if (!stale()) await fail(unexpectedProblem('crear la cápsula', err)); } finally { // A file that did not become `temp`: a failure, or a stale writing. if (t !== undefined) { diff --git a/src/routes/inspect/+page.svelte b/src/routes/inspect/+page.svelte index 2d1bf8d..dd948c7 100644 --- a/src/routes/inspect/+page.svelte +++ b/src/routes/inspect/+page.svelte @@ -3,7 +3,7 @@ import { type CapsuleBytes, inspect, readCapsule } from '$lib/dkc/index.ts'; import { FIXTURES, type Fixture, fetchFixture } from '$lib/inspector/fixtures.ts'; import { buildReport, type Report } from '$lib/inspector/report.ts'; - import { displayText, escapeInvisible, viewerTimeZone } from '$lib/inspector/format.ts'; + import { displayText, unexpectedProblem, viewerTimeZone } from '$lib/inspector/format.ts'; import { browserPlatform, CREATE_AREA, OPEN_AREA, removeStaleTempFiles } from '$lib/inspector/tempfile.ts'; import InspectionReport from '$lib/components/InspectionReport.svelte'; import OpenPanel from '$lib/components/OpenPanel.svelte'; @@ -102,7 +102,7 @@ if (/Web Crypto/.test(message)) { return 'Este navegador solo ofrece Web Crypto (crypto.subtle) en contextos seguros, y el inspector lo necesita para comprobar el perfil fijado. Abre la página por https o en localhost.'; } - return `No se pudo leer ${name}: ${escapeInvisible(message)}`; + return unexpectedProblem(`leer ${name}`, err); } function inspectFile(file: File, readNotice?: string): void { diff --git a/vite.config.ts b/vite.config.ts index d1ba47a..7eb0dbf 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -196,6 +196,24 @@ export default defineConfig({ // must be separate same-origin files, whatever their size. assetsInlineLimit: 0, }, + optimizeDeps: { + // Every dependency the pages load on demand, pre-bundled when the dev + // server starts. Found only on the first import, as the opening of a + // capsule does, the dev server bundles it then and reloads the page, and + // the import in flight fails: "Failed to fetch dynamically imported + // module". src/lib/dependencies.test.ts checks that the list is complete. + include: [ + '@noble/ciphers/chacha.js', + '@noble/curves/bls12-381.js', + '@noble/curves/ed25519.js', + '@noble/curves/nist.js', + '@noble/curves/utils.js', + '@noble/hashes/hkdf.js', + '@noble/hashes/legacy.js', + '@noble/hashes/sha2.js', + 'age-encryption', + ], + }, server: { fs: { // The dev server serves the official fixtures straight from testdata/,