From 7fe975009c1c14ad3607e6234eb0a077675afa12 Mon Sep 17 00:00:00 2001 From: Mick Beer Date: Thu, 3 Sep 2026 01:25:18 +0200 Subject: [PATCH] Two product names: ParaSend and ParaSign, and the rules page moves to /rules Snoeiplan point 4. The site presented five names while there are two products. ParaShare and ParaRules are the two that never were products, and this takes them out of the copy without breaking a single URL a visitor or an extension already holds. ParaShare, option (b): the name goes, the URL stays. /parashare is baked into every link the Chromium and Thunderbird extensions ever minted (extensions/shared/paramant-core.js), those links sit in recipients' mailboxes, and relay.js answers the retired /v2/anon-inbound with a Link: rel="successor-version" header pointing at it, which is in the sdk-js 3.x contract until the Sunset of 31-12-2026. So no code identifier, no test on a URL and no extension is touched. What changes is what a buyer reads: 27 lines across /architecture, /audit-log-export, /docs, /press, /pricing, /security, /parasend, the eyebrow on /parashare itself and the README now say "the ParaSend web app" or "the sending tool". The three legal documents keep ParaShare as a defined contract term, glossed once each as "ParaShare (the ParaSend web app)", because dropping a defined term out of live conditions is not a search and replace. The register row on /crypto-agility keeps its label: two other gates read the algorithms out of that row by name. ParaRules: pararules.html becomes rules.html, the H1 becomes "Our rules", and the nine rules and their verify links from #328 move across word for word. "ParaRule 5" becomes "rule 5" on /parasend and /parasign. /pararules is indexed, so both server confs gain a permanent 301, and phase 5c of the 3.1 runbook gains a fourth nginx edit that writes it: anchored on the ParaID deny, because paramant-live.conf carries no server_name to key on, two-pass so it lands in every site block and stays idempotent. The sitemap is regenerated. Pinned, each one sabotaged before it was believed: - no public page carries ParaShare in an H1, a card heading or a kicker, and the only pages that may carry the name at all are the three legal documents (glossed) and the crypto register row - /rules has the H1, the canonical and the nine verify links; nothing in frontend/ links to /pararules any more; both repo confs carry the 301 - seo-contract pins the new title and description of /rules - the deploy dry run proves the fourth edit lands in both confs, once each, and reports "already applied" on a second run --- README.md | 6 +- deploy/DEPLOY-3.1.md | 41 +++++-- deploy/deploy-3.1.sh | 75 +++++++++++- deploy/nginx-paramant-live.conf | 4 + deploy/nginx-paramant-public.conf | 5 + docs/brand/messaging.md | 27 +++-- docs/site-claims.md | 2 +- frontend/architecture.html | 8 +- frontend/audit-log-export.html | 2 +- frontend/docs.html | 6 +- frontend/dpa.html | 2 +- frontend/index.html | 4 +- frontend/parasend.html | 14 +-- frontend/parashare.html | 4 +- frontend/parasign.html | 2 +- frontend/press.html | 2 +- frontend/pricing.html | 4 +- frontend/privacy.html | 2 +- frontend/{pararules.html => rules.html} | 18 +-- frontend/security.html | 12 +- frontend/sitemap.xml | 42 +++---- frontend/terms.html | 2 +- tests/deploy-3.1-dryrun.test.sh | 26 ++++ tests/seo-contract.test.mjs | 9 ++ tests/ui-truthfulness.test.mjs | 155 ++++++++++++++++++++++-- 25 files changed, 370 insertions(+), 104 deletions(-) rename frontend/{pararules.html => rules.html} (96%) diff --git a/README.md b/README.md index 7ae428f5..dfc38a42 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ curl -fsSL https://paramant.app/install-pi.sh | bash ``` Or via the browser — no install: -**[Try ParaShare →](https://paramant.app/parashare)** (no account, no key needed) +**[Try the ParaSend web app →](https://paramant.app/parashare)** (no account, no key needed) **[Create a free account →](https://paramant.app/signup)** (TOTP, no password) @@ -131,7 +131,7 @@ Full set: [`docs/adrs/`](docs/adrs/) (R001–R011). | Chromium browser extension | Source in repo — server-side encryption path during client-side PQ migration ([architecture §08](https://paramant.app/architecture#components)) | | Outlook Add-in | Source in repo — server-side encryption path during client-side PQ migration ([architecture §08](https://paramant.app/architecture#components)) | -**Zero-knowledge scope:** the relay-cannot-read guarantee applies to transfers from the official SDKs (`paramant-sdk` for Python and JavaScript), the WebApp tools (ParaShare), and the anonymous `/send` flow. The Chromium and Outlook extensions currently take a server-side encryption path while their client-side hybrid crypto is being finished — until that lands, treat extension uploads as relay-side, not zero-knowledge. +**Zero-knowledge scope:** the relay-cannot-read guarantee applies to transfers from the official SDKs (`paramant-sdk` for Python and JavaScript), the ParaSend web app, and the anonymous `/send` flow. The Chromium and Outlook extensions currently take a server-side encryption path while their client-side hybrid crypto is being finished. Until that lands, treat extension uploads as relay-side, not zero-knowledge. --- @@ -556,7 +556,7 @@ The relay is **untrusted by design** — it never holds a decryption key. | Hybrid KEM (browser path) | ML-KEM-768 + ECDH P-256, combined via HKDF-SHA256 | | Symmetric | AES-256-GCM · NIST SP 800-38D | | Signatures (relay STH / receipts) | ML-DSA-65 · NIST FIPS 204 | -| Signatures (client, SDK only) | ML-DSA-65 over `ctKem ‖ senderPub ‖ nonce ‖ ct ‖ aad` (Node/Python SDK; browser ParaShare path does not yet sign client-side) | +| Signatures (client, SDK only) | ML-DSA-65 over `ctKem ‖ senderPub ‖ nonce ‖ ct ‖ aad` (Node/Python SDK; the browser web app path does not yet sign client-side) | | Key derivation | HKDF-SHA256 · RFC 5869 | | Password blobs | Argon2id · RFC 9106 | | Crypto runtime | Rust/WASM — browser-side encryption runs in native code | diff --git a/deploy/DEPLOY-3.1.md b/deploy/DEPLOY-3.1.md index c791c061..9e33efd1 100644 --- a/deploy/DEPLOY-3.1.md +++ b/deploy/DEPLOY-3.1.md @@ -382,12 +382,28 @@ rsync -rc --no-times /opt/paramant-relay/frontend/ /home/paramant/app/ nginx: main changes three things in `deploy/nginx-paramant-live.conf` (`/sign` no longer behind `auth_request`, the three `/compliance/*` locations gone, `/dicom` now `return 404`) and removes the `/compliance` lines from -`deploy/nginx-paramant-public.conf`. The server files carry the 01-09 ParaID -deny that the repo files do not, so **do not copy the repo files over them**. -Apply the three changes by hand and keep the deny: +`deploy/nginx-paramant-public.conf`. **Step 5c has a fourth edit since the two-product-names round**: +the rules page moved from `/pararules` to `/rules`, so every server block that +answers for the site gains + +```nginx +location = /pararules { return 301 https://$host/rules; } +``` + +That one is an addition, not a rewrite, and it is permanent: `/pararules` is +indexed, so the redirect is not a migration step that gets tidied away in a +later round. It is in both repo confs already, and the script writes it into +the server confs itself, anchored on the ParaID deny (`paramant-live.conf` +carries no `server_name`, so the hostname is not usable as an anchor there). +It is idempotent: a block that already has the line is left alone. If you are +doing step 5c by hand instead of through the script, this is the line to add. + +The server files carry the 01-09 ParaID deny that the repo files do not, so +**do not copy the repo files over them**. Apply the four changes by hand and +keep the deny: ```bash -nginx -T 2>/dev/null | grep -n 'paraid/issue\|location = /sign\|/compliance\|location = /dicom' +nginx -T 2>/dev/null | grep -n 'paraid/issue\|location = /sign\|/compliance\|location = /dicom\|/pararules' # edit /etc/nginx/sites-enabled/paramant-public.conf and the live conf accordingly nginx -t && systemctl reload nginx ``` @@ -398,8 +414,11 @@ round, not in this one. ### Running step 5 twice -All four nginx changes are idempotent, and the script reads each one as being -in one of three states before it edits anything: +All five nginx changes are idempotent, and the script reads the three rewrites +as being in one of three states before it edits anything. The two additions, +the `/v2/outbound` buffers and the `/pararules` 301, are counted per block +instead: a block that lacks one is a pending edit, a block that has one is left +untouched. | state | what the conf carries | what happens | |---|---|---| @@ -432,11 +451,11 @@ already succeeded. The removal of the docroot files main deleted (step 5b) already worked this way: a file that is already gone is counted as *already absent*, not as a failure. -What is asserted after the edits does not change, and that is where the weight -sits: no `auth_request` on `/sign`, zero `/compliance` locations, `/dicom` -answering `return 404`, the ParaID deny still present, `proxy_buffer_size 32k` -inside **every** `location ~ ^/v2/outbound` block, and `nginx -t` clean before -the reload. A run that changed nothing still tests and reloads nginx, so a hand +What is asserted after the edits is where the weight sits: no `auth_request` on +`/sign`, zero `/compliance` locations, `/dicom` answering `return 404`, the +ParaID deny still present, `proxy_buffer_size 32k` inside **every** +`location ~ ^/v2/outbound` block, the `/pararules` 301 inside **every** site +block, and `nginx -t` clean before the reload. A run that changed nothing still tests and reloads nginx, so a hand edit made between deploys cannot hide behind "already applied". ## Step 6: smoke tests diff --git a/deploy/deploy-3.1.sh b/deploy/deploy-3.1.sh index 7b718370..1fa4513d 100755 --- a/deploy/deploy-3.1.sh +++ b/deploy/deploy-3.1.sh @@ -1219,7 +1219,7 @@ EOF fi fi - step "5c. the three nginx changes, by hand, keeping the ParaID deny" + step "5c. the four nginx changes, by hand, keeping the ParaID deny" remote "nginx edits" "$TS" "$NGINX_SITES" "$NGINX_BACKUP_DIR" "$NGINX_CONFS" <<'EOF' set -euo pipefail TS="$1"; SITES="$2"; NGBK="$3"; CONFS="$4" @@ -1251,9 +1251,50 @@ DICOM_RE='location = /dicom[[:space:]]*\{[[:space:]]*try_files /dicom\.html' SIGN_LOC_RE='location = /sign[[:space:]]*\{' DICOM404_RE='location = /dicom[[:space:]]*\{[[:space:]]*return 404' PARAID_RE='paraid/issue' +RULES_RE='location = /pararules' OUT_RE='^[[:space:]]*location[[:space:]]*~[[:space:]]*\^/v2/outbound[[:space:]]*\{' BUF_RE='proxy_buffer_size 32k' +# /pararules became /rules in the two-product-names round. The page is indexed, +# so the old path keeps a permanent 301 rather than a migration step that gets +# tidied away in a later round. +# +# The anchor is the ParaID deny. It is in every server block by the 01-09 +# server edit the runbook already relies on and FATALs on below, which makes it +# the one line that marks a block as "a block that answers for this site" in +# both confs: paramant-live.conf carries no server_name at all (it is the +# backend paramant-public.conf proxies to), so keying on the hostname would +# have put the redirect in one conf and not the other. +# +# Two passes, like the buffer edit below: pass one learns which blocks already +# carry the redirect, pass two inserts only into the ones that do not. A +# file-wide grep would stop at the first block and leave the rest bare. +RULES_AWK=' +FNR==NR { + if ($0 ~ /^server[[:space:]]*\{/) b++ + if ($0 ~ /location = \/pararules/) has[b]=1 + if ($0 ~ /paraid\/issue/) deny[b]=1 + next +} +{ + print + if ($0 ~ /^server[[:space:]]*\{/) j++ + if ($0 ~ /paraid\/issue/ && deny[j] && !has[j] && !ins[j]) { + print " location = /pararules { return 301 https://$host/rules; }" + ins[j]=1 + } +}' + +# Count the server blocks that answer for the site (the ParaID deny marks them) +# and how many of those already carry the redirect. +RULES_COUNT_AWK=' +/^server[[:space:]]*\{/ { b++ } +/paraid\/issue/ { deny[b]=1 } +/location = \/pararules/ { has[b]=1 } +END { for (i in deny) { t++; if (has[i]) w++ } printf "%d %d\n", t+0, w+0 }' + +count_rules() { awk "$RULES_COUNT_AWK" $TARGETS | awk '{t+=$1; w+=$2} END{printf "%d %d\n", t, w}'; } + # Two-pass insert: learn which /v2/outbound blocks already carry the buffer, # then insert only into the ones that do not. BUF_AWK=' @@ -1327,6 +1368,9 @@ echo "before paraid deny = $(count "$PARAID_RE")" read -r _obt _obw <<< "$(count_blocks)" echo "before outbound locations = $_obt" echo "before outbound blocks with buffer = $_obw" +read -r _rbt _rbw <<< "$(count_rules)" +echo "before pararules blocks = $_rbt" +echo "before pararules blocks with redirect = $_rbw" # Each edit is in one of three states, and only one of them is a stop: # @@ -1379,8 +1423,10 @@ pending=0 for st in "$SIGN_STATE" "$COMP_STATE" "$DICOM_STATE"; do [ "$st" = todo ] && pending=$((pending + 1)) done -# A /v2/outbound block without the buffer is a fourth thing still to do. +# A /v2/outbound block without the buffer is a fourth thing still to do, and a +# site block without the /pararules redirect a fifth. pending=$((pending + _obt - _obw)) +pending=$((pending + _rbt - _rbw)) echo "before edits pending = $pending" if [ "$pending" -eq 0 ]; then echo "before everything already applied = yes" @@ -1420,6 +1466,10 @@ for f in $TARGETS; do awk "$BUF_AWK" "$f" "$f" > "/tmp/nginx-buf.$$" cat "/tmp/nginx-buf.$$" > "$f" rm -f "/tmp/nginx-buf.$$" + # 5. the permanent 301 from /pararules to /rules, one per site block. + awk "$RULES_AWK" "$f" "$f" > "/tmp/nginx-rules.$$" + cat "/tmp/nginx-rules.$$" > "$f" + rm -f "/tmp/nginx-rules.$$" if ! cmp -s "$f" "/tmp/nginx-pre-3.1-$(basename "$f").$TS"; then echo "edited $(basename "$f")" edited=$((edited + 1)) @@ -1437,6 +1487,10 @@ echo "after paraid deny = $(count "$PARAID_RE")" read -r _oat _oaw <<< "$(count_blocks)" echo "after outbound locations = $_oat" echo "after outbound blocks with buffer = $_oaw" +read -r _rat _raw <<< "$(count_rules)" +echo "after pararules blocks = $_rat" +echo "after pararules blocks with redirect = $_raw" +echo "after pararules redirect lines = $(count "$RULES_RE")" restore() { for b in "$NGBK"/*.pre-3.1-"$TS"; do @@ -1483,8 +1537,9 @@ EOF # How many files were rewritten depends on what was left to do, so the exact # count of 2 only holds on a run that found work in both confs. What always # holds is the END state, and that is asserted hard just below: no - # auth_request on /sign, no /compliance locations, /dicom answering 404, and - # a buffer inside every /v2/outbound block. Those four are the deploy. + # auth_request on /sign, no /compliance locations, /dicom answering 404, a + # buffer inside every /v2/outbound block, and the /pararules 301 inside every + # block that answers for the site. Those five are the deploy. if [ "$DRY_RUN" -eq 1 ]; then printf ' SKIP assert (dry-run): both named confs were rewritten, unless every edit was already applied\n' else @@ -1494,7 +1549,7 @@ EOF [ -n "$pend" ] && [ -n "$edited" ] \ || die "could not read the nginx edit state from the server" if [ "$pend" = yes ]; then - ok "nginx: already applied. All three edits and the outbound buffers were in place before this run, so nothing was rewritten (edited files = $edited)" + ok "nginx: already applied. All three edits, the outbound buffers and the /pararules 301 were in place before this run, so nothing was rewritten (edited files = $edited)" else [ "$edited" -ge 1 ] \ || die "$(remote_field 'before edits pending') nginx edit(s) were still pending but no conf was rewritten" @@ -1506,6 +1561,7 @@ EOF expect_count "after dicom try_files" 0 "/dicom no longer serves the page" expect_min "after dicom 404" 1 "/dicom now returns 404" expect_min "before outbound locations" 1 "the /v2/outbound location the buffers go on exists" + expect_min "after pararules redirect lines" 1 "/pararules answers a 301 to /rules" expect 'reloaded nginx' "nginx reloaded" if [ "$DRY_RUN" -eq 0 ]; then local ol bf @@ -1516,6 +1572,15 @@ EOF || die "proxy_buffer_size sits inside $bf of $ol /v2/outbound blocks; the inline receipt header would 502 on the rest" ok "every /v2/outbound block carries proxy_buffer_size 32k ($bf of $ol blocks)" fi + if [ "$DRY_RUN" -eq 0 ]; then + local rt rw + rt="$(remote_field 'after pararules blocks')" + rw="$(remote_field 'after pararules blocks with redirect')" + [ -n "$rt" ] && [ -n "$rw" ] || die "could not read the /pararules redirect counts from the server" + [ "$rw" = "$rt" ] \ + || die "the /pararules 301 sits in $rw of $rt site block(s); an indexed link to the old rules page would 404 on the rest" + ok "every site block redirects /pararules to /rules ($rw of $rt blocks)" + fi if [ "$DRY_RUN" -eq 0 ]; then local pb pa pb="$(remote_field 'before paraid deny')" diff --git a/deploy/nginx-paramant-live.conf b/deploy/nginx-paramant-live.conf index dcfbb9b7..ed5bffd0 100644 --- a/deploy/nginx-paramant-live.conf +++ b/deploy/nginx-paramant-live.conf @@ -105,6 +105,10 @@ server { location = /help { try_files /help/index.html =404; } location = /security { try_files /security.html =404; } location = /pentest-report-2026-04-08.txt { return 301 /security; } + # /pararules became /rules in the two-product-names round (snoei 2026-09). + # The old path is indexed, so the 301 is permanent: it is not a migration + # step that gets tidied away later. + location = /pararules { return 301 https://$host/rules; } location = /license { try_files /license.html =404; } location /api/captcha/ { proxy_pass http://127.0.0.1:4200/admin/api/captcha/; diff --git a/deploy/nginx-paramant-public.conf b/deploy/nginx-paramant-public.conf index b4490a85..c9eac5fb 100644 --- a/deploy/nginx-paramant-public.conf +++ b/deploy/nginx-paramant-public.conf @@ -59,6 +59,11 @@ server { types { application/xml xml; } } + # /pararules became /rules in the two-product-names round (snoei 2026-09). + # The old path is indexed, so the 301 is permanent: it is not a migration + # step that gets tidied away later. + location = /pararules { return 301 https://$host/rules; } + # Relay API: admin endpoints are never exposed via the public apex. # /v2/admin/* is auth-gated at the relay (returns 401), but we 404 here # to avoid leaking the existence of the admin surface on paramant.app. diff --git a/docs/brand/messaging.md b/docs/brand/messaging.md index f180e1e3..61489175 100644 --- a/docs/brand/messaging.md +++ b/docs/brand/messaging.md @@ -153,7 +153,7 @@ Fixed wording, from the rules grid on /, word for word: > Hetzner Germany, Bunny DNS (Slovenia). No US provider in the data path. Email > goes out via Resend, as /privacy sets out. -The long form, from ParaRule 5 on /pararules, word for word, and it is the +The long form, from rule 5 on /rules, word for word, and it is the version to use wherever there is room for three sentences: > Hosted in Germany, owned top to bottom in the EU. No US CLOUD Act reach over @@ -169,7 +169,7 @@ Resend sentence. Pinned by: nothing today. **To add** to `tests/ui-truthfulness.test.mjs`: assert that `frontend/index.html` still carries "No US provider in the data path" and, in the same rule, the Resend sentence with its link to /privacy; assert the same -pairing in ParaRule 5 on `frontend/pararules.html`; and assert that no public +pairing in rule 5 on `frontend/rules.html`; and assert that no public page carries "no US company in the chain", "no US provider in the chain" or "no US company" without the Resend exception beside it. See the open contradiction in section 9: /security still carries the unqualified row and has @@ -269,14 +269,20 @@ sentence here. Dutch plainness in English words. We do not have them, so we do not imply them. - Numbers only where they already appear on the site: prices, 2 signatures a month, 7 day IP log retention, 99.9% SLA, 48 hour disclosure acknowledgement. -- Claim plus checkpoint, the shape the ParaRules already use: say what it is and - say where it is checked. +- Claim plus checkpoint, the shape the rules on /rules already use: say what it + is and say where it is checked. - Where something is unfinished, say so in the same voice, without a date. The Chromium and Outlook extensions currently take a server-side encryption path and are not zero-knowledge; "In development" means not live and no date. -- Product names on commercial pages: ParaSign and ParaSend. ParaShare is the - name of the in-app sending tool and stays out of hero copy. ParaRules stays as - the name of the rules block only. +- There are two product names, and only two: **ParaSend** and **ParaSign**. + Nothing else is presented as a product, on a commercial page or anywhere else. + ParaShare is not a product name. The URL /parashare stays, because extension + links that were minted years ago point at it and the sdk-js 3.x contract names + it in a `Link: rel="successor-version"` header, but in copy the page is "the + ParaSend web app" or "the sending tool". It never appears as an H1, a kicker + or a product card, and `tests/ui-truthfulness.test.mjs` fails when it does. + The rules page is "Our rules" on /rules. "ParaRules" is retired as a name and + the individual rules are "rule 5", not "ParaRule 5"; /pararules keeps a 301. - Cryptography names, standard numbers and RAM-only appear below the fold, as the reason the top half is true. Never in an H1. @@ -366,7 +372,8 @@ it is worth a `/parasend` page in a later round, built to the /parasign pattern. Until then the buyer-facing surface for sending is the homepage product block and /pricing. Two pages exist today and keep their own jobs. -**/parashare** (the sending tool, behind sign-in, noindex, and it stays that way) +**/parashare** (the ParaSend web app, behind sign-in, noindex, and it stays that +way; the URL keeps its name, the copy does not) - **Goal:** let a signed-in user send one encrypted file. - **H1:** unchanged. @@ -480,13 +487,13 @@ its own PR and its own test. qualified (QES)." Both cannot be right. The /about wording is the one this guide pins, so the FAQ line is the one that has to move, in a separate round. 2. **/security still says "no US company".** Settled on the homepage and on - /pararules, not yet on /security. Those two pages now say no US provider in + /rules, not yet on /security. Those two pages now say no US provider in the *data path* and name Resend in the same breath, per proof 1. The Jurisdiction and privacy table on /security still has the unqualified row "US CLOUD Act: Not applicable: no US infrastructure, no US company", which reads as no US party anywhere and is broader than /privacy allows. That row is the one that has to move, in its own PR with its own test, to the data - path wording. Until it does, proof 1 is quoted from / and /pararules only. + path wording. Until it does, proof 1 is quoted from / and /rules only. Cloudflare is no longer part of this: DNS moved to Bunny and the site is served straight from Hetzner, so the name is gone from the frontend and from the out-of-scope list, which now reads "Resend, Hetzner, Bunny, Mollie". diff --git a/docs/site-claims.md b/docs/site-claims.md index 3c3803b7..d750b0f8 100644 --- a/docs/site-claims.md +++ b/docs/site-claims.md @@ -105,7 +105,7 @@ Pages not in scope: everything behind a login or in a one-shot flow |---|---|---| | Servers in Germany, Hetzner Nuremberg (NBG1), EU-only, no US CLOUD Act exposure, Bunny DNS in Slovenia, no CDN in front | index, parasign, parasend, about, security, help/index, press, privacy, dpa, sla, docs | Infrastructure, not code. Provable only against the machine (`scripts/check-prod-drift.sh` territory). The wording itself (data path plus the Resend exception) is pinned by `ui-truthfulness`; the fact is not | | "ML-KEM-768 Level 3 in production since 2024" | vs | Repository history starts 1 April 2026; press kit says founded 2025. Weaken to "in production today" | -| "Burned on read" as a universal property | index, parasend, press, pararules | `tiers.js` gives Pro 10 reads and Enterprise 100 per link, and /pricing says so. True for Community, not universal | +| "Burned on read" as a universal property | index, parasend, press, rules | `tiers.js` gives Pro 10 reads and Enterprise 100 per link, and /pricing says so. True for Community, not universal | | "All tiers meet NIS2 and GDPR requirements by design"; NEN 7510 / eIDAS / IEC 62443 mappings "ship with Pro and Enterprise" | pricing, parasend, security, docs | After #323 the norm pages are gone. The only artefact left is `docs/security/COMPLIANCE-CHECKLIST.md`. The "no third-party certification" disclaimer is pinned by `pricing-page.test.js`; the mapping deliverable itself is not. This is the largest remaining norm claim on the site | | The sector paragraphs on /docs (NEN 7510, DICOM, HL7 FHIR, eIDAS, KNB, ISO 20022, DORA, IEC 62443) | docs | Positioning for five relays that run identical software. Nothing in the code is sector-specific except the `SECTORS` map and the per-sector env presets in `relay.js` | | Named support within one business day (Business); "published response targets" (Pro); "We respond within 48 hours" on three help pages; press replies within 24 hours; complaints answered within fourteen days | pricing, parasign, index, help/*, press, terms, sla | Service commitments, not code. Keep only if someone will honour them | diff --git a/frontend/architecture.html b/frontend/architecture.html index 853e8889..f42e2055 100644 --- a/frontend/architecture.html +++ b/frontend/architecture.html @@ -612,13 +612,13 @@

Where it runs

07

Verified transfer

-

Every Paramant transfer is account-bound and end-to-end encrypted in the browser. ParaShare adds post-quantum key exchange, ML-DSA-65 sender signatures, and CT-log proof of origin. There is no anonymous or lower-assurance tier.

+

Every Paramant transfer is account-bound and end-to-end encrypted in the browser. The ParaSend web app adds post-quantum key exchange, ML-DSA-65 sender signatures, and CT-log proof of origin. There is no anonymous or lower-assurance tier.

- + @@ -660,7 +660,7 @@

Verified transfer

08

Components

-

The relay is one piece. Around it sit official SDKs, two browser extensions, and the WebApp tools (ParaShare). Each surface is at a different stage of the wire-format-v1 migration. Here is the honest state today.

+

The relay is one piece. Around it sit official SDKs, two browser extensions, and the ParaSend web app. Each surface is at a different stage of the wire-format-v1 migration. Here is the honest state today.

PropertyVerified
ParaShare
Verified
web app
@@ -687,7 +687,7 @@

Components

- + diff --git a/frontend/audit-log-export.html b/frontend/audit-log-export.html index 18c0a654..86b8968a 100644 --- a/frontend/audit-log-export.html +++ b/frontend/audit-log-export.html @@ -190,7 +190,7 @@

Burn-on-read delivery with a cryptographic receipt.

From export to evidence.

-
01
DPO or legal counsel exports the audit log package and uploads via ParaShare
+
01
DPO or legal counsel exports the audit log package and uploads via the ParaSend web app
02
Paramant generates an encrypted one-time link; relay holds only ciphertext
03
Link is transmitted to the DPA investigator or auditor (the sensitive data does not travel by email)
04
Investigator downloads; relay RAM is cleared immediately after
diff --git a/frontend/docs.html b/frontend/docs.html index e7f53cf3..355ffa50 100644 --- a/frontend/docs.html +++ b/frontend/docs.html @@ -855,8 +855,8 @@

Burn-on-read

The CT log persists cryptographic hashes (Merkle leaf hashes, tree hashes, device hashes) to disk: never payload content. Even a complete disk image of the relay server reveals no file contents.

Two-step burn: first the blob is zeroed in-place, then the Map entry is deleted. This prevents a brief window where a partial read could occur.

-

5 MB fixed padding (ParaShare)

-

ParaShare authenticated blobs are padded to exactly 5,242,880 bytes (5 MiB) with cryptographically random bytes before upload. The block size tells an observer nothing about the file, but a transfer larger than one block is sent as several blocks and its total_chunks travels in the clear, so the number of blocks is visible and the file size can be placed within an order of magnitude. That is finding 5 of the April 2026 audit, an accepted trade-off.

+

5 MB fixed padding (ParaSend web app)

+

Authenticated blobs from the ParaSend web app are padded to exactly 5,242,880 bytes (5 MiB) with cryptographically random bytes before upload. The block size tells an observer nothing about the file, but a transfer larger than one block is sent as several blocks and its total_chunks travels in the clear, so the number of blocks is visible and the file size can be placed within an order of magnitude. That is finding 5 of the April 2026 audit, an accepted trade-off.

Certificate Transparency log

Every transfer hash and device registration is appended to a public Merkle tree. The root hash changes with every write and is publicly verifiable. Anyone can prove a transfer occurred without learning what was transferred.

@@ -921,7 +921,7 @@

Security: threat model

- +
Real post-quantum (@noble/post-quantum). Produces v1 blobs.
ParaShare (webapp)ParaSend web app Verified, authenticated transfers from the browser. Hybrid ML-KEM-768 + ECDH P-256 + AES-256-GCM via WASM, client-side. Pre-v1 blob layout. Migration to v1 in progress.
What a compromised relay can doWhat it cannot do
Deny or delay serviceRead file contents (no decryption key)
Learn transfer timing and frequencySubstitute a registered ML-KEM public key (CT log prevents rollback)
Observe that a ParaShare blob arrived (all fixed 5 MB, content opaque)Forge relay signatures (ML-DSA-65 key is per-relay, signed)
Observe that a web app blob arrived (all fixed 5 MB, content opaque)Forge relay signatures (ML-DSA-65 key is per-relay, signed)
Block a specific device hashDecrypt any stored ciphertext

The threat model assumes network-level attackers, compromised relay operators, and post-quantum adversaries. Encryption uses a hybrid scheme (ML-KEM-768 + ECDH P-256) so that breaking either primitive is not sufficient: both must be broken simultaneously.

diff --git a/frontend/dpa.html b/frontend/dpa.html index 0653de19..3c3ba2a3 100644 --- a/frontend/dpa.html +++ b/frontend/dpa.html @@ -235,7 +235,7 @@

2. Subject matter and scope

3. Nature, purpose, and duration of processing

- + diff --git a/frontend/index.html b/frontend/index.html index 8299c01b..2275db24 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -390,7 +390,7 @@

ParaSend

why you can trust it

Rules you can check. Every one has a proof.

-

Everyone has a privacy policy. The ParaRules come with a verify link each: a log, a header, a repo. Not a promise. Four of the nine are below.

+

Everyone has a privacy policy. Our rules come with a verify link each: a log, a header, a repo. Not a promise. Four of the nine are below.

paramant-coreML-KEM-768 encryption · ML-DSA-65 signatures · FIPS 203/204

    @@ -400,7 +400,7 @@

    Rules you can check. Every one has a proof.

  1. Verifiable, not trust-me

    Open-core, a public CT log, receipts anyone can check offline.

    verify: CT log
diff --git a/frontend/parasend.html b/frontend/parasend.html index 50a560e6..33bcf651 100644 --- a/frontend/parasend.html +++ b/frontend/parasend.html @@ -258,7 +258,7 @@

Scrambled here, unreadable in transit, gon
It opens on their device, or nowhere
-

The file is locked to a key registered on the recipient's device, so it opens there and nowhere else, and a file altered on the way does not open at all. ParaShare is the send screen you use; ParaSend is the product it belongs to.

+

The file is locked to a key registered on the recipient's device, so it opens there and nowhere else, and a file altered on the way does not open at all. The send screen you use for this is the ParaSend web app.

Lock a file for yourself
@@ -280,7 +280,7 @@

Three steps to a file that disappears

Sending needs a Paramant account. Receiving needs nothing at all.

  1. Create a free account. No card, no time limit. The free tier sends with a 1 hour link that burns on first read, forever.
  2. -
  3. Open ParaShare, the send screen of ParaSend, and pick your file. It is scrambled in your browser before anything is uploaded, so what our servers receive is unreadable, and they keep it in memory only.
  4. +
  5. Open the ParaSend web app and pick your file. It is scrambled in your browser before anything is uploaded, so what our servers receive is unreadable, and they keep it in memory only.
  6. Share the link. The recipient opens it once and the file is wiped. On the free tier the link expires after an hour whether it is opened or not.
@@ -317,7 +317,7 @@

What each plan gives you

  • 1 hour link expiry
  • Burn on first read
  • Up to 50 retrievals an hour through the API
  • -
  • Up to 5 registered devices for ParaShare, the ParaSend send screen
  • +
  • Up to 5 registered devices for the ParaSend web app
  • @@ -371,19 +371,19 @@

    Every claim has a place to check it

    We never had it
    -

    AES-256-GCM with a key your browser generates. The relay only ever holds ciphertext, never the plaintext and never the key, and it holds it in RAM until the read burns it. A ParaShare transfer is stored at a fixed 5 MB, so its size says nothing about the file. The retention row is on the security page.

    +

    AES-256-GCM with a key your browser generates. The relay only ever holds ciphertext, never the plaintext and never the key, and it holds it in RAM until the read burns it. A web app transfer is stored at a fixed 5 MB, so its size says nothing about the file. The retention row is on the security page.

    The wire format, in full
    -

    ParaShare uses ML-KEM-768 plus ECDH P-256 hybrid key exchange. The register lists the webapp on the pre-v1 hybrid wire, signature n/a, migrating to v1; ML-DSA-65 signed receipts are live on the v1 wire in sdk-py 3.0.0 and sdk-js 3.0.0. Read the register on the crypto agility page.

    +

    The ParaSend web app uses ML-KEM-768 plus ECDH P-256 hybrid key exchange. The register lists the webapp on the pre-v1 hybrid wire, signature n/a, migrating to v1; ML-DSA-65 signed receipts are live on the v1 wire in sdk-py 3.0.0 and sdk-js 3.0.0. Read the register on the crypto agility page.

    You own your keys
    -

    Generated on your device, never sent. We see only the public half. Read the ParaRules.

    +

    Generated on your device, never sent. We see only the public half. Read our rules.

    EU soil, EU law
    -

    Hetzner Germany, Bunny DNS (Slovenia). No US provider in the data path. Email goes out via Resend, as /privacy sets out. The subprocessors are listed there, and ParaRule 5 states the same claim in full on ParaRules.

    +

    Hetzner Germany, Bunny DNS (Slovenia). No US provider in the data path. Email goes out via Resend, as /privacy sets out. The subprocessors are listed there, and rule 5 states the same claim in full on our rules page.

    No third-party requests
    diff --git a/frontend/parashare.html b/frontend/parashare.html index 7713b703..966393db 100644 --- a/frontend/parashare.html +++ b/frontend/parashare.html @@ -328,7 +328,7 @@
    - PARASHARE + PARASEND

    Encrypted file relay

    End-to-end encrypted file transfer. The receiver's browser generates a keypair — you verify the fingerprint before encrypting. The relay never sees plaintext.

    @@ -337,7 +337,7 @@

    Encrypted file relay

    -
      +
      1. 1 · Setup diff --git a/frontend/parasign.html b/frontend/parasign.html index c2c6ebb4..2bf6e4f5 100644 --- a/frontend/parasign.html +++ b/frontend/parasign.html @@ -360,7 +360,7 @@

        The technique is the evidence, not the pit

    EU soil, EU law
    -

    Hetzner Germany, Bunny DNS (Slovenia). No US provider in the data path. Email goes out via Resend, as /privacy sets out. The subprocessors are listed there, and ParaRule 5 states the same claim in full on ParaRules.

    +

    Hetzner Germany, Bunny DNS (Slovenia). No US provider in the data path. Email goes out via Resend, as /privacy sets out. The subprocessors are listed there, and rule 5 states the same claim in full on our rules page.

    diff --git a/frontend/press.html b/frontend/press.html index c22a8c47..e844429e 100644 --- a/frontend/press.html +++ b/frontend/press.html @@ -283,7 +283,7 @@

    Screenshots & product images

    -

    ParaShare — file transfer UI

    +

    ParaSend web app — file transfer UI

    Browser-based send/receive. No install.

    Open your dashboard →
    diff --git a/frontend/pricing.html b/frontend/pricing.html index fb8e7830..9947725b 100644 --- a/frontend/pricing.html +++ b/frontend/pricing.html @@ -427,7 +427,7 @@

    Create a free account →

    @@ -496,7 +496,7 @@

    Limits

    Encryption

    -

    Every tier uses the same cryptography. Verified transfers via ParaShare use ML-KEM-768 plus ECDH P-256 hybrid key exchange; the crypto register lists ParaShare with no default signature algorithm, on a pre-v1 hybrid wire format that is migrating to v1. There is no lesser encryption at lower tiers. Cryptography is not a paywalled feature at Paramant.

    +

    Every tier uses the same cryptography. Verified transfers from the ParaSend web app use ML-KEM-768 plus ECDH P-256 hybrid key exchange; the crypto register lists the web app with no default signature algorithm, on a pre-v1 hybrid wire format that is migrating to v1. There is no lesser encryption at lower tiers. Cryptography is not a paywalled feature at Paramant.

    diff --git a/frontend/privacy.html b/frontend/privacy.html index 5f94b0f0..a271203d 100644 --- a/frontend/privacy.html +++ b/frontend/privacy.html @@ -210,7 +210,7 @@

    Core principle

    PARAMANT collects only what is strictly required to operate your account and run the service.

    An email address is required to create an account and recover access; it is personal data, and it is stored. No phone number. We do not log IP addresses for analytics or profiling; an IP is processed only transiently, for security and abuse-prevention (rate limiting, the session record, an audit log, and the DPA signature record). Only a strictly-necessary session cookie (no tracking cookies). No tracking pixels. No analytics. No advertisements. We never sell your data.

    -

    How Ghost Pipe & ParaShare work

    +

    How Ghost Pipe & ParaShare (the ParaSend web app) work

    All files are encrypted client-side in your browser using post-quantum cryptography (ML-KEM-768 + ECDH P-256 + AES-256-GCM + HKDF-SHA256) before they ever reach our servers. The relay never sees plaintext at any point.

    Files are split into 5 MB encrypted chunks. Each chunk exists only in RAM on our relay server and is permanently and irreversibly destroyed after the first download (burn-on-read). Encrypted payload data is never written to disk. From a transfer, the only thing persisted to disk is cryptographic hashes in the public Certificate Transparency log: no file content, no keys, no plaintext. (Account data, listed below, is stored separately to run your account.)

    Community plan blobs expire after 1 hour maximum. Pro blobs after 24 hours. Enterprise blobs after 7 days. All are destroyed earlier if downloaded.

    diff --git a/frontend/pararules.html b/frontend/rules.html similarity index 96% rename from frontend/pararules.html rename to frontend/rules.html index 3ddd8d90..a85fec48 100644 --- a/frontend/pararules.html +++ b/frontend/rules.html @@ -4,21 +4,21 @@ -ParaRules · What Paramant stands for +Our rules · What Paramant stands for - + - + - + - + @@ -120,9 +120,9 @@ "@graph": [ { "@type": "WebPage", - "@id": "https://paramant.app/pararules#webpage", - "url": "https://paramant.app/pararules", - "name": "ParaRules · What Paramant stands for", + "@id": "https://paramant.app/rules#webpage", + "url": "https://paramant.app/rules", + "name": "Our rules · What Paramant stands for", "isPartOf": { "@id": "https://paramant.app/#website" }, @@ -204,7 +204,7 @@
    -

    ParaRules

    +

    Our rules

    What Paramant stands for, and how we build it.

    These are the rules. Not marketing copy. They are the constraints we hold ourselves to, in the product and in the code. If we ever break one, hold us to it.

    diff --git a/frontend/security.html b/frontend/security.html index 3f2b8599..d85fbc9e 100644 --- a/frontend/security.html +++ b/frontend/security.html @@ -233,7 +233,7 @@
    EVIDENCE

    Security

    -

    Break into our own server and you still cannot read the documents on it: they are encrypted on your device before they leave it, and the key never reaches us. That holds for the web app, ParaShare and the official SDKs. The Chromium and Outlook extensions still encrypt on our server, which means we can read what you upload through them until that is changed; the detail is further down this page.

    +

    Break into our own server and you still cannot read the documents on it: they are encrypted on your device before they leave it, and the key never reaches us. That holds for the ParaSend web app and the official SDKs. The Chromium and Outlook extensions still encrypt on our server, which means we can read what you upload through them until that is changed; the detail is further down this page.

    This is the evidence under everything the rest of the site promises, and it covers every plan: the Community tier gets exactly the same protection as the paid ones.

    Paramant is a product of Paramantis Solutions B.V. in Harderwijk, the Netherlands, KvK 42115132, founded by Mick Beer, privacy and security researcher.

    @@ -289,7 +289,7 @@

    Jurisdiction & privacy

    - + @@ -306,7 +306,7 @@

    Core principle

    Where the zero-knowledge guarantee holds today
    -

    The guarantee above applies to transfers from the official SDKs (sdk-py 3.0.0, sdk-js 3.0.0) and the WebApp tools (ParaShare), each of which encrypts in the client before bytes leave the device. The Chromium and Outlook extensions currently take a server-side encryption path while their client-side crypto is being finished; until that migration lands, treat extension uploads as relay-side, not zero-knowledge. Per-client status: crypto-agility § 06.

    +

    The guarantee above applies to transfers from the official SDKs (sdk-py 3.0.0, sdk-js 3.0.0) and the ParaSend web app, each of which encrypts in the client before bytes leave the device. The Chromium and Outlook extensions currently take a server-side encryption path while their client-side crypto is being finished; until that migration lands, treat extension uploads as relay-side, not zero-knowledge. Per-client status: crypto-agility § 06.

    What was audited

    @@ -338,7 +338,7 @@

    Cryptography

    NatureGhost Pipe and ParaShare payloads are stored in RAM only and destroyed after retrieval or TTL expiry. ParaSign request documents are encrypted in the browser and may be persisted as ciphertext until the signing envelope expires.
    NatureGhost Pipe and ParaShare (the ParaSend web app) payloads are stored in RAM only and destroyed after retrieval or TTL expiry. ParaSign request documents are encrypted in the browser and may be persisted as ciphertext until the signing envelope expires.
    PurposeSecure point-to-point file transfer and encrypted delivery of documents for signing as instructed by the Controller
    DurationCoterminous with the service subscription. Transfer payloads are destroyed within their transfer TTL. ParaSign ciphertext and envelope records are destroyed at envelope expiry.
    Personal data categoriesAny personal data embedded in transferred files (content encrypted, invisible to processor); API key contact email; device identifiers (hashed in CT log)
    Server locationHetzner Nuremberg, Germany
    Legal jurisdictionEU / Germany (GDPR)
    US CLOUD ActNo US provider in the data path: files and keys stay in the EU. Transactional email goes via Resend, a US provider, which never receives the document. See privacy.
    Data retainedNo plaintext, no keys. Only: transfer hash, creation timestamp, approximate encrypted size (up to 5 MB; exact 5 MB for ParaShare), view count. Deleted on burn.
    Data retainedNo plaintext, no keys. Only: transfer hash, creation timestamp, approximate encrypted size (up to 5 MB; exact 5 MB for the ParaSend web app), view count. Deleted on burn.
    IP loggingNginx access logging is switched off in the deploy configuration (access_log off on every server block that serves the site and the relays in deploy/nginx-paramant-live.conf). Not linked to transfer content. No separate retention period is promised.
    AnalyticsNone. No third-party scripts on the relay or API.

    Hybrid key exchange

    -

    ParaShare authenticated transfers use ML-KEM-768 and ECDH P-256 simultaneously. The shared secret is the concatenation of both, so breaking one provides no advantage. This protects against store-now-decrypt-later attacks while maintaining compatibility with classical infrastructure.

    +

    Authenticated transfers from the ParaSend web app use ML-KEM-768 and ECDH P-256 simultaneously. The shared secret is the concatenation of both, so breaking one provides no advantage. This protects against store-now-decrypt-later attacks while maintaining compatibility with classical infrastructure.

    Pre-shared secret (PSS)

    @@ -359,7 +359,7 @@

    Relay architecture

    5 MB padding
    -

    ParaShare authenticated uploads are padded to a fixed 5 MB block for DPI masking. A single block reveals no file size, type or content. A transfer that needs more than one block sends its chunk count in the clear, so the number of blocks is visible and the size can be placed within an order of magnitude (audit finding 5, accepted).

    +

    Authenticated uploads from the ParaSend web app are padded to a fixed 5 MB block for DPI masking. A single block reveals no file size, type or content. A transfer that needs more than one block sends its chunk count in the clear, so the number of blocks is visible and the size can be placed within an order of magnitude (audit finding 5, accepted).

    ML-DSA-65 relay identity
    @@ -373,7 +373,7 @@

    What a compromised relay cannot do

    Read file contents: relay holds only ciphertext, never keys
    Substitute a registered public key (after TOFU or PSS is used)
    Decrypt any stored ciphertext: no key access
    -
    Read an exact ParaShare file size: every block is a fixed 5 MB, and only the block count is visible
    +
    Read an exact file size: every web app block is a fixed 5 MB, and only the block count is visible
    Recover burned blobs: memory is zeroed immediately on download
    Forge CT log entries: Merkle chain is append-only and tamper-evident
    Break ML-KEM-768: NIST FIPS 203, post-quantum secure
    diff --git a/frontend/sitemap.xml b/frontend/sitemap.xml index 2d74e553..6b6e6c4a 100644 --- a/frontend/sitemap.xml +++ b/frontend/sitemap.xml @@ -8,12 +8,12 @@ https://paramant.app/architecture - 2026-09-02 + 2026-09-03 0.8 https://paramant.app/audit-log-export - 2026-09-02 + 2026-09-03 0.6 @@ -33,7 +33,7 @@ https://paramant.app/docs - 2026-09-02 + 2026-09-03 0.8 @@ -48,7 +48,7 @@ https://paramant.app/dpa - 2026-09-02 + 2026-09-03 0.6 @@ -73,7 +73,7 @@ https://paramant.app/help/gmail-extension - 2026-09-02 + 2026-09-03 0.6 @@ -83,12 +83,12 @@ https://paramant.app/help/iot-integration - 2026-09-02 + 2026-09-03 0.6 https://paramant.app/help/lost-authenticator - 2026-09-02 + 2026-09-03 0.6 @@ -98,12 +98,12 @@ https://paramant.app/help/session-issues - 2026-09-02 + 2026-09-03 0.6 https://paramant.app/ - 2026-09-02 + 2026-09-03 0.9 @@ -111,19 +111,14 @@ 2026-09-02 0.6 - - https://paramant.app/pararules - 2026-09-02 - 0.6 - https://paramant.app/parasend - 2026-09-02 + 2026-09-03 0.9 https://paramant.app/parasign - 2026-09-02 + 2026-09-03 0.9 @@ -133,22 +128,27 @@ https://paramant.app/press - 2026-09-02 + 2026-09-03 0.6 https://paramant.app/pricing - 2026-09-02 + 2026-09-03 0.9 https://paramant.app/privacy - 2026-09-02 + 2026-09-03 + 0.6 + + + https://paramant.app/rules + 2026-09-03 0.6 https://paramant.app/security - 2026-09-02 + 2026-09-03 0.8 @@ -178,7 +178,7 @@ https://paramant.app/terms - 2026-09-02 + 2026-09-03 0.6 diff --git a/frontend/terms.html b/frontend/terms.html index deecefa7..783f0a93 100644 --- a/frontend/terms.html +++ b/frontend/terms.html @@ -235,7 +235,7 @@

    Terms of Service

    1. Who you are contracting with

    PARAMANT is operated by Paramantis Solutions B.V., established in Harderwijk, the Netherlands, registered with the Dutch Chamber of Commerce under KvK 42115132. In these terms "we", "us" and "PARAMANT" mean that company. "You" means the person or organisation using the service.

    -

    These terms apply to every use of paramant.app, the relay, the dashboard, ParaSend, ParaSign, ParaShare and the API. They apply from the moment you create an account or use the service without one.

    +

    These terms apply to every use of paramant.app, the relay, the dashboard, ParaSend, ParaSign, ParaShare (the ParaSend web app) and the API. They apply from the moment you create an account or use the service without one.

    These terms cover the hosted service at paramant.app. The software is licensed separately under the Business Source License 1.1, which permits free non-commercial, personal and research use, including self-hosting. Running your own relay is governed by that licence, not by this document.

    2. What the service is

    diff --git a/tests/deploy-3.1-dryrun.test.sh b/tests/deploy-3.1-dryrun.test.sh index fe0f4e99..80944adf 100755 --- a/tests/deploy-3.1-dryrun.test.sh +++ b/tests/deploy-3.1-dryrun.test.sh @@ -455,6 +455,26 @@ if [ "$(field_5c "$OUT1" 'after outbound blocks with buffer')" \ else fail "the buffer landed in $(field_5c "$OUT1" 'after outbound blocks with buffer') of $(field_5c "$OUT1" 'after outbound locations') blocks" fi +# /pararules moved to /rules in the two-product-names round. The old path is +# indexed, so the 301 has to be in every block that answers for the site, not +# just the first one the edit happened to walk into. +if [ "$(field_5c "$OUT1" 'before pararules blocks with redirect')" = "0" ]; then + pass "the fixture starts without the /pararules redirect, so the edit has work to do" +else + fail "the fixture already carries the redirect; this run would prove nothing" +fi +if [ "$(field_5c "$OUT1" 'after pararules blocks with redirect')" \ + = "$(field_5c "$OUT1" 'after pararules blocks')" ] \ + && [ "$(field_5c "$OUT1" 'after pararules blocks')" = "2" ]; then + pass "every site block came out with the 301 from /pararules to /rules" +else + fail "the 301 landed in $(field_5c "$OUT1" 'after pararules blocks with redirect') of $(field_5c "$OUT1" 'after pararules blocks') site blocks" +fi +if [ "$(field_5c "$OUT1" 'after pararules redirect lines')" = "2" ]; then + pass "the redirect was written once per conf, not twice" +else + fail "5c wrote $(field_5c "$OUT1" 'after pararules redirect lines') redirect line(s), expected 2" +fi echo "" echo "6g-2. Second run on the same confs: already applied, not FATAL" @@ -472,6 +492,8 @@ else fi for want in "before sign state:done" "before compliance state:done" "before dicom state:done" \ "before edits pending:0" "before everything already applied:yes" \ + "before pararules blocks with redirect:2" \ + "after pararules redirect lines:2" \ "after edited files:0"; do f="${want%%:*}"; v="${want##*:}" if [ "$(field_5c "$OUT2" "$f")" = "$v" ]; then pass "second run reports $f = $v"; else @@ -510,6 +532,10 @@ check_has "$SCRIPT" 'already applied' \ "the script has an already-applied verdict instead of a FATAL" check_has "$FULL" 'before edits pending' "the dry run shows the pending-edit count" check_has "$FULL" 'before sign state' "the dry run shows the per-edit state read" +check_has "$SCRIPT" 'location = /pararules { return 301 https://\$host/rules; }' \ + "phase 5c writes the permanent 301 from /pararules to /rules" +check_has "$SCRIPT" 'the four nginx changes' \ + "the 5c step name counts the /pararules redirect as one of the edits" echo "" echo "6g-4. A multi-line auth_request on /sign is todo, never already applied" diff --git a/tests/seo-contract.test.mjs b/tests/seo-contract.test.mjs index fd1097b8..84672e18 100644 --- a/tests/seo-contract.test.mjs +++ b/tests/seo-contract.test.mjs @@ -399,6 +399,11 @@ test('every public page names the company and the founder in its Organization no // release is v0.2.1 of 28 March 2026 and none has followed. // trust frontend/trust.html tags every claim live or planned. // signup the ParaSign Community card on frontend/pricing.html. +// rules frontend/rules.html carries the nine rules and their verify +// links. It was /pararules until the two-product-names round; +// pinned here because the retired brand is exactly what creeps +// back into a title, and the old URL keeps a 301 that only pays +// off while the new page is the one that is indexed. // // The title is pinned as well as the description. A title is the one sentence // that gets shared, and the gate above only pins the copies to each other: all @@ -436,6 +441,10 @@ const PINNED = { title: 'Create a free Paramant account', desc: 'Create a Paramant account. ParaSign Community gives 2 signatures a month, unlimited receiving and full post-quantum crypto, forever. No card required.', }, + rules: { + title: 'Our rules · What Paramant stands for', + desc: 'The rules Paramant holds itself to: zero third-party requests, EU sovereignty, post-quantum by default, you own your keys, honest by design.', + }, }; test('the pages that carry the offer say exactly what they are pinned to say', () => { diff --git a/tests/ui-truthfulness.test.mjs b/tests/ui-truthfulness.test.mjs index f7cb25b8..7428ab45 100644 --- a/tests/ui-truthfulness.test.mjs +++ b/tests/ui-truthfulness.test.mjs @@ -350,16 +350,18 @@ const unsold = [...new Set(planNames)].filter((name) => !new RegExp(`>\\s*${name assert.deepEqual(unsold, [], `the dashboard shows these plan names, but /pricing does not sell them: ${unsold.join(', ')}`); -// Every ParaRule must carry a way to check it, because the homepage says so: -// "The ParaRules come with a verify link each". Measured before this gate: +// Every rule must carry a way to check it, because the homepage says so: +// "Our rules come with a verify link each". Measured before this gate: // nine rules, zero links, one mailto in the footer. -const pararules = read('frontend/pararules.html'); -const guarantees = pararules.slice(pararules.indexOf('

    What we guarantee

    '), pararules.indexOf('

    How we build it

    ')); +// The page was /pararules until the two-product-names round; the nine rules and +// their verify links moved across word for word, only the name went. +const rulesPage = read('frontend/rules.html'); +const guarantees = rulesPage.slice(rulesPage.indexOf('

    What we guarantee

    '), rulesPage.indexOf('

    How we build it

    ')); const ruleCount = (guarantees.match(/

    \d+\s*·/g) || []).length; const verifyCount = (guarantees.match(/class="rule-verify"/g) || []).length; assert.equal(verifyCount, ruleCount, - `pararules.html has ${ruleCount} rules but ${verifyCount} verify links; the homepage promises one each`); -assert.ok(ruleCount >= 9, `expected at least nine ParaRules, found ${ruleCount}`); + `rules.html has ${ruleCount} rules but ${verifyCount} verify links; the homepage promises one each`); +assert.ok(ruleCount >= 9, `expected at least nine rules, found ${ruleCount}`); // ── The same answer on the account page ────────────────────────────────────── // A customer who wants to know what he pays goes to /account, not /dashboard, @@ -480,7 +482,7 @@ for (const slug of frontendPages()) { assert.deepEqual(tierNameHits, [], `these use Free as the name of a plan; it is called Community:\n ${tierNameHits.join('\n ')}\n`); -// The ParaRules grid on the homepage shows a SELECTION. It used to print the +// The rules grid on the homepage shows a SELECTION. It used to print the // numbers 01, 03, 04 and 06, which reads as two rules gone missing rather than // as four chosen. Either the numbers go or they run consecutively. const ruleNumbers = [...home.matchAll(/(\d+)<\/span>/g)].map((m) => Number(m[1])); @@ -824,8 +826,8 @@ console.log('ui-truthfulness: /docs and /help answer a buyer with sentences that // a sentence may only go on the site if it is already true on the site or in // the code, and there is a test that fails when it stops being true. Every // string below is quoted from a page that ships, and the guide names each of -// them as work to pin. Where the guide flagged a claim as disputed (ParaRule -// 04's "no US provider in the chain", section 9) the claim is NOT asserted +// them as work to pin. Where the guide flagged a claim as disputed (rule 04's +// "no US provider in the chain", section 9) the claim is NOT asserted // here and does not appear on the product pages; only the /security row it // rests on is. // A claim is what a visitor reads, not how the markup happens to be wrapped. @@ -859,7 +861,7 @@ const signVisibleText = visible('frontend/sign.html'); // Proof 1. The EU claim is about the data path, not the whole chain, and the // Resend exception travels with it. A page may shorten the long form on -// /pararules to this one; it may never drop the second half. +// /rules to this one; it may never drop the second half. const EU_CLAIM = 'Hetzner Germany, Bunny DNS (Slovenia). No US provider in the data path.'; const EU_EXCEPTION = 'Email goes out via Resend'; const homeVisible = visible('frontend/index.html'); @@ -892,7 +894,7 @@ for (const [name, text] of [['parasign', parasign], ['parasend', parasend]]) { // table qualified the broader claim. Section 9.2 of the guide names that row // as the one that has to move to the data-path wording in its own PR with its // own test. This is that PR. What proof 1 is measured against on /security is -// now the same sentence / and /pararules carry, with its Resend exception; the +// now the same sentence / and /rules carry, with its Resend exception; the // window check that keeps the two together lives further down this file. assert.ok(!/no US company/i.test(security), 'security.html must no longer claim "no US company", which is broader than /privacy supports'); @@ -1217,7 +1219,7 @@ console.log('ui-truthfulness: the messaging guide claims are pinned to the pages // while ten screens lower the page says the Chromium and Outlook extensions // take a server-side encryption path. For an extension user the flat version // is untrue today, so the exception travels with the promise. - assert.match(securityHero, /That holds for the web app, ParaShare and the official SDKs\./, + assert.match(securityHero, /That holds for the ParaSend web app and the official SDKs\./, 'security.html must scope the zero-knowledge promise in the hero'); assert.match(securityHero, /The Chromium and Outlook extensions still encrypt on our server/, 'security.html must name the extension exception in the same breath as the promise'); @@ -1666,3 +1668,132 @@ console.log('ui-truthfulness: no page or script promises transfers without a cei })(); console.log('ui-truthfulness: no ParaSign surface sells a transfers ceiling the ParaSign grant does not deliver'); + + +// ── Two product names, and only two ────────────────────────────────────────── +// +// docs/brand/messaging.md section 6: there are two products, ParaSend and +// ParaSign. ParaShare was never a product, only the label on the send screen, +// and it read as a fifth brand on the perskit, the pricing page and five claims +// on /security. The name is gone from copy. Two things it is NOT gone from, on +// purpose, and neither is a name a buyer reads: +// +// /parashare the URL. Every link the Chromium and Thunderbird +// extensions ever minted is baked against it +// (extensions/shared/paramant-core.js), those links sit in +// recipients' mailboxes, and relay.js answers the retired +// /v2/anon-inbound with a Link: rel="successor-version" +// header pointing at it, which is in the sdk-js 3.x +// contract until the 31-12-2026 Sunset. The URL cannot move. +// the register row ParaShare (webapp) on /crypto-agility is a +// technical key: two other gates read the algorithms out of +// that row by that label (this file, above, and +// relay/test/pricing-page.test.js). +// +// Everywhere else the name may only appear as a glossed contract term in the +// three legal documents, where dropping a defined term out of live conditions +// is not a search and replace. +(function twoProductNamesOnly() { + const dir = new URL('../frontend/', import.meta.url); + const pages = []; + const walk = (rel) => { + for (const entry of fs.readdirSync(new URL(rel, dir), { withFileTypes: true })) { + if (entry.isDirectory()) { + if (entry.name !== 'node_modules' && entry.name !== 'vendor') walk(`${rel}${entry.name}/`); + } else if (entry.name.endsWith('.html')) { + pages.push(`${rel}${entry.name}`); + } + } + }; + walk(''); + assert.ok(pages.length > 30, 'the page walk found almost nothing; this gate would read nothing'); + + // The legal term, glossed. A gloss on the first mention is what makes the + // term readable; a bare mention on a page that never defines it is not. + const LEGAL = new Set(['terms.html', 'privacy.html', 'dpa.html']); + const REGISTER = 'crypto-agility.html'; + const GLOSS = 'ParaShare (the ParaSend web app)'; + + const strays = []; + for (const page of pages) { + const html = read('frontend/' + page); + if (!html.includes('ParaShare')) continue; + if (LEGAL.has(page)) { + assert.ok(html.includes(GLOSS), + `${page} uses ParaShare as a contract term without ever defining it; it must read "${GLOSS}" at least once`); + continue; + } + if (page === REGISTER) { + assert.ok(/ParaShare \(webapp\)<\/td>/.test(html), + 'crypto-agility.html may carry ParaShare only as the register row two other gates read by that label'); + continue; + } + strays.push(page); + } + assert.deepEqual(strays, [], `these pages present ParaShare in copy again: ${strays.join(', ')}`); + + // And nowhere, not even on the four pages above, in the three places a + // reader takes for a product name: the H1, the kicker over it, or the + // heading on a card. The eyebrow on /parashare said PARASHARE until this + // round, which is exactly the shape this looks for. + const shouty = []; + for (const page of pages) { + const html = read('frontend/' + page); + const spots = [ + ...html.matchAll(/]*>([\s\S]*?)<\/h1>/gi), + ...html.matchAll(/]*>([\s\S]*?)<\/h3>/gi), + ...html.matchAll(/]*class="[^"]*eyebrow[^"]*"[^>]*>([\s\S]*?)<\/span>/gi), + ...html.matchAll(/]*class="[^"]*eyebrow[^"]*"[^>]*>([\s\S]*?)<\/p>/gi), + ]; + for (const m of spots) { + if (/parashare|pararules/i.test(m[1])) shouty.push(`${page}: ${m[1].replace(/\s+/g, ' ').trim()}`); + } + } + assert.deepEqual(shouty, [], + `a heading, card or kicker presents a retired name as a product:\n ${shouty.join('\n ')}\n`); +})(); + +console.log('ui-truthfulness: ParaSend and ParaSign are the only two product names in copy'); + + +// ── The rules page is "Our rules" on /rules ────────────────────────────────── +// +// The nine rules and their verify links (#328) moved word for word; only the +// name went. /pararules is in Google, so it keeps a permanent 301 in both +// server confs the runbook edits, and nothing on the site may point at the old +// path any more: a link that takes a redirect is a link that will rot the day +// someone tidies the redirect away. +(function rulesPageMoved() { + const rules = read('frontend/rules.html'); + assert.match(rules, /

    Our rules<\/h1>/, 'frontend/rules.html must carry the H1 the page is named for'); + assert.match(rules, //, + 'rules.html must point its canonical at the new URL, or the 301 sends the signal back and forth'); + assert.ok(!/ParaRule/i.test(rules), 'rules.html carries the retired name again'); + + const sitemap = read('frontend/sitemap.xml'); + assert.ok(sitemap.includes('https://paramant.app/rules'), '/rules is missing from the sitemap'); + assert.ok(!sitemap.includes('/pararules'), 'the sitemap still lists the retired /pararules URL'); + + // No internal link may take the redirect. + const dir = new URL('../frontend/', import.meta.url); + const linkers = []; + const walk = (rel) => { + for (const entry of fs.readdirSync(new URL(rel, dir), { withFileTypes: true })) { + if (entry.isDirectory()) { + if (entry.name !== 'node_modules' && entry.name !== 'vendor') walk(`${rel}${entry.name}/`); + } else if (entry.name.endsWith('.html') || entry.name.endsWith('.js')) { + if (read('frontend/' + rel + entry.name).includes('/pararules')) linkers.push(rel + entry.name); + } + } + }; + walk(''); + assert.deepEqual(linkers, [], `these files still link to the retired /pararules: ${linkers.join(', ')}`); + + // The 301 itself, in both confs deploy/deploy-3.1.sh phase 5c names. + for (const conf of ['deploy/nginx-paramant-public.conf', 'deploy/nginx-paramant-live.conf']) { + assert.match(read(conf), /location = \/pararules \{ return 301 https:\/\/\$host\/rules; \}/, + `${conf} has no 301 from /pararules to /rules; every indexed link to the old page would 404`); + } +})(); + +console.log('ui-truthfulness: the rules page is Our rules on /rules, with /pararules redirected');