Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
a7fb88b
docs: plan security review remediation
jomplox Aug 24, 2026
316df2e
fix(worker): gate real Wompi links on fiscal readiness
jomplox Aug 24, 2026
8238bdb
fix(worker): validate fiscal MH endpoint lanes
jomplox Aug 24, 2026
3598d56
fix: guard local pdf artifacts
jomplox Aug 24, 2026
331eb76
fix: require step-up MFA after distributed login failures
jomplox Aug 24, 2026
02abd61
fix(worker): preserve provider-safe readiness failures
jomplox Aug 24, 2026
7bb6a37
fix: bound login MFA reissuance attempts
jomplox Aug 24, 2026
69325d9
fix(worker): narrow Wompi readiness error handling
jomplox Aug 24, 2026
db50a48
fix(worker): bound provider creation budgets
jomplox Aug 24, 2026
dea96e1
fix(worker): gate Stripe retry generations
jomplox Aug 24, 2026
0aa0eb8
fix(worker): reject conflicting Wompi replays
jomplox Aug 24, 2026
26305fa
fix(worker): compare Wompi replay bodies losslessly
jomplox Aug 24, 2026
ef9f921
fix(worker): sanitize MH provider responses
jomplox Aug 24, 2026
bf27961
fix(worker): redact cached MH bearer variants
jomplox Aug 24, 2026
5b59682
fix(worker): normalize cached MH authorization
jomplox Aug 24, 2026
d1c6952
fix(worker): anchor retention verification in D1
jomplox Aug 24, 2026
827a0c6
fix(docs): align retention digest order
jomplox Aug 24, 2026
70731d6
fix(audit): enforce account audit audience before query
jomplox Aug 24, 2026
c8f2092
fix(worker): emit HSTS on production responses
jomplox Aug 24, 2026
1230716
test(worker): prove repeated cookie preservation
jomplox Aug 24, 2026
8e1a327
fix(worker): harden MH and Stripe cleanup boundaries
jomplox Aug 24, 2026
5a2d82e
fix: address PR 181 review findings
jomplox Aug 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions README.es.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ DiezmosSV/
│ ├── client/ # Panel React + Vite, /donar, fuentes, recursos
│ └── shared/ # Catálogos · DUI · NIT · ventanas legales · política de contraseñas
│ # correcciones fiscales · entrega · montos · correo
├── migrations/ # Esquema D1 (incremental, solo se agrega, 0001…0044)
├── migrations/ # Esquema D1 (incremental, solo se agrega, 0001…0047)
├── DTE/svfe-json-schemas/ # Esquemas JSON de MH para validación
├── docs/ # Despliegue/UAT · manual del operador · restauración de retención
│ # cutover/conciliación de claims fiscales · recuperación previa al CDE
Expand Down Expand Up @@ -607,7 +607,7 @@ configuración privada seleccionada y se duplican por ambiente de Wrangler:
| `ARCHIVE` (binding) | Binding del bucket de R2 para la exportación mensual de retención legal y los objetos del logo de marca blanca. La configuración de ejemplo versionada nombra `diezmossv-local-archive-example`, `diezmossv-staging-archive-example` y `diezmossv-production-archive-example`; los nombres reales de bucket pertenecen únicamente a la configuración privada seleccionada. |
| `EMAIL_ARBITRARY_RECIPIENTS` | Marcador opcional `"true"` que se define después de confirmar que Cloudflare Email Sending puede alcanzar direcciones externas de donantes. El ejemplo versionado ya lo define para `staging`; local y producción lo dejan sin definir. |
| `DONATION_INTAKE_DISABLED` | Interruptor de emergencia para nueva recepción pública. Cuando vale exactamente `"true"`, las mutaciones de intentos Wompi y `POST /api/donations/stripe/checkout` responden `503 donation_intake_disabled`; `/`, `/donar` y `/donar/gracias` sirven un documento vacío y cerrado. La página de resultado de Stripe, lecturas de estado, webhook, recibos y Billing Portal siguen disponibles para no dejar varado a un donante existente o mensual. El webhook de Wompi, la tubería de emisión y el panel de administración también siguen funcionando. El ejemplo versionado lo fija en `"true"` para `production`; sin definir o con cualquier otro valor, la recepción queda abierta. |
| `MH_AUTH_URL_*` · `MH_RECEPCION_URL_*` · `MH_ANULACION_URL_*` | Endpoints de MH disponibles solo para el carril de credenciales del despliegue. `MH_AUTH_URL_TEST_FALLBACK` es el respaldo acotado de autenticación central para cuentas TEST tras el código 106 de MH; no es una capacidad de transmisión en PROD. |
| `MH_AUTH_URL_*` · `MH_RECEPCION_URL_*` · `MH_ANULACION_URL_*` | Endpoints de MH disponibles solo para el carril de credenciales del despliegue. `MH_AUTH_URL_TEST_FALLBACK` puede estar ausente/vacío, ser la URL oficial exacta de autenticación TEST (sin efecto), o la URL exacta de autenticación central `https://api.dtes.mh.gob.sv/seguridad/auth` para cuentas TEST tras el código 106 de MH. Cualquier otro valor se rechaza antes de la recepción por Wompi y antes de enviar credenciales al respaldo; no es una capacidad de transmisión en PROD. |
| `MH_USER_AGENT` | Encabezado User-Agent enviado a MH. |
| `EMISOR_CONFIG_JSON` | La configuración del emisor de demostración/local vive en el archivo de entorno privado seleccionado; el valor remoto real se define como secreto de Cloudflare. |
| `STRIPE_RESTRICTED_KEY` | Clave de servidor `rk_test_…` (staging) o `rk_live_…` (producción), con privilegios mínimos para Checkout Sessions y Billing Portal. Se rechazan las claves amplias `sk_…`. |
Expand Down Expand Up @@ -1075,7 +1075,7 @@ El modelo de seguridad es el modelo del claim fiscal aplicado a una ruta de repa
## 📚 Modelo de datos

<details>
<summary><strong>Tablas de D1 (migrations/0001_init.sql, extendidas hasta la 0044)</strong></summary>
<summary><strong>Tablas de D1 (migrations/0001_init.sql, extendidas hasta la 0047)</strong></summary>

<br/>

Expand Down Expand Up @@ -1103,8 +1103,9 @@ El modelo de seguridad es el modelo del claim fiscal aplicado a una ruta de repa
| `stripe_invoice_settlement_retention_generations` | Libro interno y monotónico de pertenencia para instantáneas de convergencia de facturas mensuales. No forma parte del payload archivado y se reconstruye automáticamente al restaurar. |
| `contingency_batches` · `contingency_batch_lines` | Envíos históricos de lotes de contingencia a MH y sus resultados por CDE (solo lectura). |
| `app_settings` | Configuración en tiempo de ejecución (ambiente de emisión, plantillas de correo, marca, correo de alertas). |
| `users` · `sessions` · `password_reset_tokens` | Autenticación, RBAC y restablecimiento de contraseña autogestionado. |
| `login_rate_limits` · `security_rate_limit_claims` | Limitación de tasa respaldada en D1 para el inicio de sesión, el restablecimiento de contraseña y los intentos públicos de donación, con la procedencia del claim registrada en las filas que admite. |
| `users` · `sessions` · `password_reset_tokens` · `login_step_up_challenges` | Autenticación, RBAC, restablecimiento de contraseña autogestionado y desafíos breves de verificación escalonada de cuenta, almacenados solo como hashes. |
| `login_rate_limits` · `security_rate_limit_claims` | Limitación de tasa respaldada en D1 para el inicio de sesión, el restablecimiento de contraseña y las capacidades de datos del donante. |
| `provider_creation_claims` | Presupuestos atómicos de 15 minutos por cliente, proveedor y global para enlaces Wompi nuevos y estado Checkout de Stripe. Los clientes IPv6 comparten un `/64` normalizado; las filas padre conservan la procedencia del claim después de barrer el libro temporal. |

Las claves foráneas están habilitadas (`PRAGMA foreign_keys = ON`). El acceso es SQL crudo a través de
`src/worker/storage/repository.ts` — sin ORM.
Expand Down
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ DiezmosSV/
│ ├── client/ # React + Vite admin panel, /donar, fonts, assets
│ └── shared/ # Catalogs · DUI · NIT · legal windows · password policy
│ # fiscal corrections · checkout · money · email
├── migrations/ # D1 schema (incremental, append-only 0001…0044)
├── migrations/ # D1 schema (incremental, append-only 0001…0047)
├── DTE/svfe-json-schemas/ # MH-bundled JSON schemas for validation
├── docs/ # Deploy/UAT · operator runbook · retention-restore
│ # fiscal-claim cutover/reconciliation · pre-CDE recovery
Expand Down Expand Up @@ -588,7 +588,7 @@ selected private config and are duplicated per Wrangler environment:
| `ARCHIVE` (binding) | R2 bucket binding for the monthly legal-retention export and the white-label logo objects. The committed example config names `diezmossv-local-archive-example`, `diezmossv-staging-archive-example`, and `diezmossv-production-archive-example`; real bucket names belong only in the selected private config. |
| `EMAIL_ARBITRARY_RECIPIENTS` | Optional `"true"` marker set after Cloudflare Email Sending is confirmed able to reach external donor addresses. The committed example already sets it for `staging`; local and production leave it unset. |
| `DONATION_INTAKE_DISABLED` | Emergency kill switch for new public intake. When exactly `"true"`, Wompi intent mutations and `POST /api/donations/stripe/checkout` return `503 donation_intake_disabled`; `/`, `/donar`, and `/donar/gracias` serve an empty locked-down document. Stripe's result page, status reads, webhook, acknowledgments, and Billing Portal remain available so an existing or monthly donor is not stranded. The Wompi webhook, issuance pipeline, and admin panel also keep working. The committed example sets it to `"true"` for `production`; unset or any other value leaves intake open. |
| `MH_AUTH_URL_*` · `MH_RECEPCION_URL_*` · `MH_ANULACION_URL_*` | MH endpoints available only for the deployment's credential lane. `MH_AUTH_URL_TEST_FALLBACK` is the narrow central-auth fallback for TEST accounts after MH code 106; it is not a PROD transmission capability. |
| `MH_AUTH_URL_*` · `MH_RECEPCION_URL_*` · `MH_ANULACION_URL_*` | MH endpoints available only for the deployment's credential lane. `MH_AUTH_URL_TEST_FALLBACK` may be absent/empty, the exact official TEST auth URL (a no-op), or the exact central auth URL `https://api.dtes.mh.gob.sv/seguridad/auth` for TEST accounts after MH code 106. Every other value is rejected before Wompi collection and before fallback credentials are sent; it is not a PROD transmission capability. |
| `MH_USER_AGENT` | User-Agent header sent to MH. |
| `EMISOR_CONFIG_JSON` | Demo/local issuer config lives in the selected private env file; set the real remote value as a Cloudflare secret. |
| `STRIPE_RESTRICTED_KEY` | Server-only `rk_test_…` (staging) or `rk_live_…` (production) key with least privilege for Checkout Sessions and Billing Portal. Broad `sk_…` keys are rejected. |
Expand Down Expand Up @@ -1037,7 +1037,7 @@ The safety model is the fiscal-claim model applied to a repair path:
## 🗄 Data model

<details>
<summary><strong>D1 tables (migrations/0001_init.sql, extended through 0044)</strong></summary>
<summary><strong>D1 tables (migrations/0001_init.sql, extended through 0047)</strong></summary>

<br/>

Expand Down Expand Up @@ -1065,8 +1065,9 @@ The safety model is the fiscal-claim model applied to a repair path:
| `stripe_invoice_settlement_retention_generations` | Internal monotonic membership ledger for monthly-invoice convergence snapshots. It is not an archive payload and is rebuilt automatically on restore. |
| `contingency_batches` · `contingency_batch_lines` | Historical MH contingency batch submissions and per-CDE results (read-only). |
| `app_settings` | Runtime settings (emission environment, email templates, branding, alert email). |
| `users` · `sessions` · `password_reset_tokens` | Authentication, RBAC, and self-service password reset. |
| `login_rate_limits` · `security_rate_limit_claims` | D1-backed rate limiting for login, password reset, and public donation intents, with claim provenance recorded on the rows they admit. |
| `users` · `sessions` · `password_reset_tokens` · `login_step_up_challenges` | Authentication, RBAC, self-service password reset, and short-lived hashed account step-up verification challenges. |
| `login_rate_limits` · `security_rate_limit_claims` | D1-backed rate limiting for login, password reset, and donor-data capabilities. |
| `provider_creation_claims` | Atomic 15-minute client, provider, and global budgets for new Wompi links and Stripe Checkout state. IPv6 clients share a normalized `/64`; parent rows retain claim provenance after the expiring ledger is swept. |

Foreign keys are enabled (`PRAGMA foreign_keys = ON`). Access is raw SQL via
`src/worker/storage/repository.ts` — no ORM.
Expand Down
81 changes: 60 additions & 21 deletions docs/retention-restore.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,16 @@ multi-year retention tax law requires survives independently of D1.
retention/<YYYY>/<YYYY-MM>/manifest.json
retention/<YYYY>/<YYYY-MM>/runs/<run-id>/dte_documents.ndjson
retention/<YYYY>/<YYYY-MM>/runs/<run-id>/fiscal_corrections.ndjson
retention/<YYYY>/<YYYY-MM>/runs/<run-id>/fiscal_corrections_latest.ndjson
retention/<YYYY>/<YYYY-MM>/runs/<run-id>/donation_intents.ndjson
retention/<YYYY>/<YYYY-MM>/runs/<run-id>/dte_events.ndjson
retention/<YYYY>/<YYYY-MM>/runs/<run-id>/email_deliveries.ndjson
retention/<YYYY>/<YYYY-MM>/runs/<run-id>/audit_logs.ndjson
retention/<YYYY>/<YYYY-MM>/runs/<run-id>/fiscal_corrections_latest.ndjson
retention/<YYYY>/<YYYY-MM>/runs/<run-id>/wompi_events.ndjson
retention/<YYYY>/<YYYY-MM>/runs/<run-id>/document_sequences.ndjson
retention/<YYYY>/<YYYY-MM>/runs/<run-id>/contingency_periods.ndjson
retention/<YYYY>/<YYYY-MM>/runs/<run-id>/contingency_batches.ndjson
retention/<YYYY>/<YYYY-MM>/runs/<run-id>/contingency_batch_lines.ndjson
retention/<YYYY>/<YYYY-MM>/runs/<run-id>/document_sequences.ndjson
retention/<YYYY>/<YYYY-MM>/runs/<run-id>/stripe_checkout_sessions.ndjson
retention/<YYYY>/<YYYY-MM>/runs/<run-id>/stripe_webhook_events.ndjson
retention/<YYYY>/<YYYY-MM>/runs/<run-id>/stripe_gifts.ndjson
Expand Down Expand Up @@ -61,16 +61,17 @@ infer a failed issuance from an absent field or invent a reservation/error
during restore.

Every run writes to a fresh immutable `<run-id>` prefix. `manifest.json` is
published **last** with a conditional create and is the authoritative completion
marker. If two runs overlap, only one can publish the month manifest; the losing
run cannot overwrite the winning files because their object keys differ. A later
re-run skips and audits `RETENTION_EXPORT_SKIPPED`. Version 1 manifests without
run-scoped keys remain readable for legacy restores. A version 2 manifest looks like:
published with a conditional create after all table objects. If two runs overlap,
only one can publish the month manifest; the losing run cannot overwrite the
winning files because their object keys differ. The winner then appends a live D1
`RETENTION_EXPORT_COMPLETED` audit from the same in-memory manifest. A later re-run
skips and audits `RETENTION_EXPORT_SKIPPED`; it never creates or repairs completion
evidence from R2. A version 2 manifest looks like:

```json
{
"version": 2,
"runId": "example-run-id",
"runId": "example-run-id",
"month": "2026-06",
"generatedAt": "2026-07-01T09:00:03.512Z",
"tables": {
Expand All @@ -88,9 +89,30 @@ run-scoped keys remain readable for legacy restores. A version 2 manifest looks
}
```

The real manifest contains one keyed entry for every table listed above. Never
construct a version 2 table path from the month or from an untrusted run ID; use
the exact `tables.<name>.key` recorded by the canonical manifest.
The real manifest is an exact version 2 schema: those five root fields and only
those fields, plus exactly the 18 table entries listed above. Every entry contains
only `key`, a non-negative safe-integer `rowCount`, and a lowercase 64-hex
`sha256`. Its key must be exactly
`retention/<YYYY>/<YYYY-MM>/runs/<runId>/<table>.ndjson`; partial, empty, extra,
wrong-month, wrong-run, or malformed manifests are invalid. Consumers rebuild the
table map in the canonical order shown above. Never construct a table path from an
untrusted run ID; use the exact key only after the whole manifest passes this
schema.

New completion audits contain `month`, `runId`, `generatedAt`, `totalRows`, the
same exact `tables` map, and `manifestSha256`. The digest is SHA-256 over compact
UTF-8 JSON with root fields ordered `version`, `runId`, `month`, `generatedAt`,
`tables`; tables in the 18-table order above; and entry fields ordered `key`,
`rowCount`, `sha256`. Historical completion audits shaped exactly as
`{month,totalRows,tables}` remain acceptable only when the total and the entire
strict table map match. A present but malformed or mismatched new or historical
audit fails closed.

The publish/audit order deliberately leaves a fail-closed crash gap: a Worker that
publishes the immutable manifest and crashes before appending the D1 audit leaves
R2 objects that listing/download can parse, but verification rejects them as
unanchored. Do not synthesize an anchor from those objects; investigate the failed
export and preserve the evidence.

## 1. List what's in the archive

Expand All @@ -102,7 +124,16 @@ To list all objects for a given month without downloading each one, use the
R2 API/dashboard (`wrangler r2 object` operates on a single key at a time) or
`aws s3 ls` against R2's S3-compatible endpoint if configured.

## 2. Verify manifest hashes match the archived bodies
## 2. Verify the D1 anchor, then the archived bodies

Use the authenticated admin verification action in the target environment first.
It parses the exact manifest and looks up the latest live D1
`RETENTION_EXPORT_COMPLETED` audit for `entity_type=retention_export` and the same
month. The exact map (and, for new evidence, run ID, timestamp, total, and canonical
manifest digest) must match before the Worker reads or hashes any table body. No
anchor, a malformed latest anchor, or any mismatch creates
`RETENTION_VERIFY_FAILED`, sends an operational alert, and never creates
`RETENTION_VERIFIED`.

Download each `.ndjson` at the exact `key` referenced in the manifest and confirm its SHA-256
matches the recorded hash before trusting it for a restore:
Expand All @@ -118,6 +149,11 @@ Repeat for every table listed in the manifest. If any hash mismatches, the
object was corrupted or tampered with after being written — do not use it for
a restore; escalate before proceeding.

Repository tests prove this fail-closed contract with controlled D1/R2 fakes;
they are not evidence that a particular live R2 bucket or D1 database currently
contains a valid anchored month. Record the target, time, actor, and resulting
live `RETENTION_VERIFIED` audit when performing an operational verification.

## 3. Re-import NDJSON into D1

Each line in a `.ndjson` file is a full row as it existed in D1 at export
Expand Down Expand Up @@ -238,8 +274,10 @@ Stripe snapshots are intended for an empty loss-recovery database. If restoring
into a database with existing Stripe rows, compare rows by primary/unique key
and stop for manual review on any difference; never overwrite immutable annual
snapshot/lineage evidence or turn REVIEW/SENT delivery evidence backward.
Archives created before these Stripe snapshot files existed remain valid legacy
archives, but they cannot reconstruct Stripe gifts and no missing row may be
Historical artifacts created before these Stripe snapshot files existed are not
exact v2 manifests and the current verifier will not label them archived. If an
incident requires separate forensic recovery from one, treat it as an incomplete
historical input: it cannot reconstruct Stripe gifts, and no missing row may be
manufactured from an audit entry.

Do not concatenate every repeated Wompi snapshot. Restore historical
Expand Down Expand Up @@ -306,9 +344,10 @@ rehearsal they belong inside the existing `BEGIN IMMEDIATE` transaction. An
upsert or trigger-recreation failure must roll back the whole restore; never
leave the allocation trigger absent.

Archives created before `fiscal_corrections_latest.ndjson` are valid legacy
archives. Restore their historical `fiscal_corrections.ndjson` rows as they
exist and do not invent a later outcome. When any newer verified archive
Historical artifacts created before `fiscal_corrections_latest.ndjson` are not
exact v2 manifests and require separate forensic review. If independently
accepted for recovery, restore their historical `fiscal_corrections.ndjson` rows
as they exist and do not invent a later outcome. When any newer verified archive
contains the authoritative snapshot, overlay that newest snapshot last. The
snapshot repeats only the already protected correction row in R2; it does not
copy receptor JSON into audit metadata, and it remains behind the same audited
Expand All @@ -328,10 +367,10 @@ Wompi reservation maximum is the greatest non-null
fiscal-correction reservation maximum is the greatest non-null
`fiscal_corrections.reserved_control_sequence` for the same
environment/`reserved_control_prefix`. If one source has no row, omit that
term (or treat it as `1`). Archives created before
`document_sequences.ndjson` existed are valid legacy archives: derive the
counter from the document, Wompi, and fiscal-correction reservation maxima
instead of assuming `1`.
term (or treat it as `1`). Historical artifacts created before
`document_sequences.ndjson` existed are not exact v2 manifests. If separate
forensic review accepts one for recovery, derive the counter from the document,
Wompi, and fiscal-correction reservation maxima instead of assuming `1`.

Never move an existing counter backward. When restoring into a database that
already has a counter, compare its current `next_value` with the formula above
Expand Down
Loading