Legal pages: eight promises pinned to the code, seven of them corrected - #372
Merged
Conversation
Apolloccrypt
force-pushed
the
fix/legal-texts-match-practice
branch
3 times, most recently
from
September 3, 2026 00:51
be61ac4 to
2f50d47
Compare
The pass covered /privacy, /dpa, /terms, /license and the retention and sub-processor claims on /security, read against relay.js, tiers.js, envelope.js, ct-hash.js, the deploy configuration and LICENSE. What was wrong: LICENSE grants a conversion to the Apache License 2.0 on 2029-01-01. /license said MIT on 1 January 2030 in its prose, in its licence box and in all four head copies, and /terms repeated it. Corrected to the file. tiers.js gives Pro 10 reads per link, Business 25 and Enterprise 100. /privacy stated burn-on-read as a property of every plan, and left Business out of the expiry list. So did /terms. The relay and the admin call two external hosts. /dpa, which customers sign, listed one of them: Mollie was missing from the sub-processor table while /privacy named it. Added, and Bunny.net (DNS, Slovenia) put on the /privacy list the rest of the site already names. A signing envelope is kept 30 days by default and 365 at most, and a delivery receipt for 15 minutes. /privacy said "until its expiry" for the first and nothing for the second. ct-hash.js is SHA3-256 throughout. /dpa said so, /privacy said SHA-256. The browser-storage list named ps_free_uses and pm_docs_key, neither of which exists anywhere in the repository, and described a 10 a day limit that is 10 a month. It did not name the five keys the frontend writes. The IP-logging row on /security described the hosted configuration only. The self-host configuration in this repository logs the client address. README named Frankfurt in the table that claims EU jurisdiction; the DPA and /privacy name Nuremberg. Also: the real_ip comment in nginx-paramant-live.conf called Bunny the edge. The A record points at this machine, so it is DNS only. Rows 23 to 30 in docs/site-claims.md, all pinned in tests/site-claims.test.mjs and each verified by sabotaging its source.
… DPA scope Four things the reviewer caught, and the tests that hold each of them. LICENSE grants production use up to five active API keys and one self-hosted deployment, commercial use included. Nothing said so. deploy/LICENSE shipped no Additional Use Grant at all while its Terms referred to one "above"; the box on /license granted non-commercial use only, under a different Licensor and a different Licensed Work; /terms repeated the narrow version twice. Block 23 now compares the Additional Use Grant, the Licensor and the Licensed Work word for word across LICENSE, deploy/LICENSE and the box, and forbids any page from selling the licence as non-commercial only. Mollie is a real sub-processor: both billing routes are wired and createPayment is unconditional, so the DPA row stays. What the row claimed it sends was wrong both ways. The payload is the amount, a description and metadata of accountId, product, plan and interval, and no e-mail: the only path that would send one is ensureCustomer, skipped while BILLING_MODE is unset. /privacy and /terms still said billing was not live and plans were arranged by hand, two versions behind the code. Block 31 reads the payload, calls billingStance() for the stance, and pins all three pages to both. POST /v2/sign-dpa is public and checks a name, an organisation, an e-mail and a rate limit. Four pages described four different gates; "applies to all plans" was the one that matched. Block 32 pins the endpoint's lack of a gate to that sentence, and /audit-log-export no longer claims the relay processes no personal data, which was the opposite of the position the whole of /dpa takes. Three smaller ones. Block 33 makes the auditd and CIS figures on /dpa equal to the ones SECURITY.md records, without claiming either proves the host is in that state. Block 34 requires every page carrying the "Not eIDAS-qualified" disclaimer to name the level the signature does reach; /sign and /co-sign only said what it is not. Block 35 replaces "processed only transiently" on /privacy with the two logs that really hold an address: three admin lines, one of them with the account e-mail, in a container log rotated on volume, and the edge access log that scripts/access-log-visitors.mjs reads. Block 26 now pins the envelope retention on /dpa as well, in days rather than "until expiry". Every new assertion was sabotaged in both directions, source and page.
/press and /architecture both said "No US entity in the chain" while the signed /dpa table names Resend Inc. in the US under Standard Contractual Clauses, and /dpa section 6 spells the transfer out. That is the site contradicting the document a controller signs, on the one point where the answer is a jurisdiction and not a nuance. Both sentences now say what is true: no US entity holds ciphertext or a key, and the one American sub-processor sends transactional email under SCCs. Block 25 reads the US row out of the /dpa table and forbids the denial on any public page while that row stands. Sabotaged in both directions: putting the sentence back turns it red, and so does moving the Resend row to the EU.
All four copies on /license (description, og, twitter, JSON-LD) sold the licence as free for personal use. The Additional Use Grant gives more than that: five active API keys and one self-hosted deployment in production, commercial use included. seo-contract pins the four to each other, so they were consistently understating the same thing. Rewritten to name the grant, inside the 165 character budget seo-contract enforces.
The previous commit took two em-dashes out of the Additional Use Grant in LICENSE so the style guard would pass. That is the wrong way round. LICENSE decides what anyone may do with this software, and rewriting it to satisfy a punctuation rule is a decision for the licensor, not for a shell script in scripts/. Both dashes are back, exactly as origin/main has them, deploy/LICENSE is byte identical to the root again, and the box on /license reproduces the grant with them. The guard gets the exception instead, and only for rule 1. LICENSE and deploy/LICENSE are exempt on path. On /license the exemption is the region between LICENCE-VERBATIM-START and LICENCE-VERBATIM-END, applied per line number: a dash added anywhere else on that page still fails. Emoji and AI attribution stay forbidden everywhere, the licence text included, and a commit message with a dash is still refused even when it edits the licence. Block 23 is untouched and still compares the Additional Use Grant, the Licensor and the Licensed Work word for word across the three copies, so the exemption buys freedom from the punctuation rule and no freedom at all from the test. This commit is its own proof: it adds both dashes and the guard passes.
The first version matched a whole template literal with a backtick character class. It found three lines on this machine and none on the runner, so the suite went red in CI with "admin/server.js now writes 0 log lines with a full client IP" against a file that plainly writes three. A pin that is green where it is cheap and red where it counts is worse than no pin. A log call is one line in that file, so it now reads one line at a time: skip a commented-out line, require the call, require the interpolation. No backtick class and no brace escapes, so nothing is left for a regex engine to disagree about. The two markers are built by concatenation for the same reason: the test file must be able to name them without a template literal reading them as its own. Same two assertions, three lines and one of them carrying the account email, and both sabotages still turn it red: a fourth line with the client IP, and the pending-signup line losing the address.
… open CI was red on this branch for a reason that had nothing to do with the runner. The block asserted that admin/server.js writes three log lines with a full client IP. It did when the block was written. #374 landed first and routed all three through admin/lib/log-redact.js, so on the merge commit the count is zero and the assertion was right to fail: /privacy was describing a system that no longer exists. The page now says what the code does. An address is truncated to its network before it is written and an email keeps one character and its domain, so the container log holds neither in full. The section is renamed accordingly and the admin entry stands as contrast rather than as the finding. The block keeps only what this pull request actually adds: the edge access log, which no page described, pinned to scripts/access-log-visitors.mjs. The claim about the admin lines belongs to row 9 and admin/test/log-redact.test.js, which pin it at the source, and a second weaker copy here would only rot again. What remains of that half is a guard: /privacy may not go back to describing raw lines, and the masking it does describe has to still exist. Five sabotages red in both directions: dropping the section, restoring "only transiently", pointing the reader at another file, renaming maskIpForLog, and putting the raw-lines sentence back.
Leftovers from the version of that section that had an admin half. The core principle paragraph, the client-IP row in the processing table and the "what we never do" bullet each pointed at "the logs named below" in the plural. One log holds a full address now, the edge access log, and the page says one.
The audit trail. The row this branch wrote said the audit log was "capped by volume, not by time". Both halves were wrong. admin/lib/audit.js sets a 400 day default retention and trims by score as well as by rank, so there is an age bound; and what it stores is not a client IP but its network part, 1.2.x.x for IPv4 and the first two groups for IPv6. Understating a retention is not being careful, it is being wrong in the direction that flatters us. Both figures and the mask are now read off the file. The norm mappings. /pricing said the three ship with Pro and Enterprise, and the last round called the claim unbacked. Both were wrong. docs/ot-guide.md carries an IEC 62443 requirement table and docs/dicom-guide.md a NEN 7510 table, both served from frontend/docs/ where a Community account reads them for nothing. The false half was never "these exist"; it was the paid-tier framing. eIDAS is the one with no mapping document, and the page says so. Block 36 requires the sections in both copies, forbids a plan in front of them, and goes red if an eIDAS mapping appears so the sentence changes with it. The timer. /press said blobs are "destroyed on download, not on a timer" while every tier in tiers.js carries a view_ttl_ms. Saying a thing exists where it does not is one mistake; denying a thing that does is another, and this one fits in a sweep. No page may make the denial, and the ceilings /press names are read off tiers.js so a changed TTL moves the page too. The licence box. It called itself a word for word copy while missing the MariaDB trademark clause and the whole Covenants section and carrying a copyright line LICENSE does not have. The field pins could not see any of that: gutting a shared paragraph stayed green. The box is the whole file now, and block 23 compares the whole box to it with whitespace normalised, on top of the field pins. Eleven sabotages, source side and page side for each new assertion.
Apolloccrypt
force-pushed
the
fix/legal-texts-match-practice
branch
from
September 3, 2026 01:18
749bc39 to
0a1d8ae
Compare
…ooked at #382 took the signed DPA out of the Enterprise item on the homepage. This is what stops it coming back, and it reads both shapes a tier list takes here: the one-line form on /index, and the tier-name heading with its own list on /pricing and /parasend. The first version scanned list items only and walked straight past the card, which the sabotage caught: putting the bullet back on /pricing stayed green. Reading the card found /parasend saying the same untrue thing, in a list nobody had looked at during three rounds on this branch. Same fix as /pricing: custom terms are what Enterprise negotiates, the standard agreement is public and asks nobody for a plan. The homepage sentence itself is pinned too, so the fix cannot be deleted quietly. The Enterprise card on /pricing also listed the norm documentation flat, one screen above the paragraph in this same pull request saying a Community account reads it for nothing. The bullet says which it is now. The equivalent line on /parasend already carries its caveat and is pinned verbatim by relay/test/pricing-page.test.js, so it stays as it is. Sabotages: the DPA bullet back on /index, /pricing and /parasend, the homepage sentence deleted, and the tier headings renamed to check the sweep is not counting nothing.
This was referenced Sep 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Read the legal pages against the code:
/privacy,/dpa,/terms,/license, and the retention and sub-processor claims on/securityand/parasend. Sources:relay/relay.js,relay/lib/tiers.js,relay/envelope.js,relay/lib/ct-hash.js,relay/lib/mollie.js,relay/lib/billing-recurring.js,admin/server.js,docker-compose.yml,deploy/nginx-*.conf,LICENSE,SECURITY.md.Fourteen rows in
docs/site-claims.md(23 to 36), all pinned intests/site-claims.test.mjs, each one verified by sabotaging both the source and the page and watching the block go red.What was wrong
LICENSE:5-6,deploy/LICENSE:5-6LICENSE:8-18LICENSE:8-18vsdeploy/LICENSE:6-8before thisseo-contractpins the four to each other, so they understated it togetherLICENSE:8-18admin/lib/audit.js:10,14-20,35-42docs/ot-guide.md:221,docs/dicom-guide.md:11-17,190-198view_ttl_ms: 1 hour, 24 hours, 7 days, 7 daysrelay/lib/tiers.js:49,58,67,77POST /v2/sign-dpaasks nobody for a plan. Found by the sweep #382 prompted, in a list three rounds had missedrelay/relay.js:3585-3620deploy/LICENSEand the box are now the file word for word, em-dashes includedLICENSE:8-18LICENSE:3-4relay/lib/tiers.jsmax_viewsrelay/lib/tiers.jsview_ttl_msapi.mollie.comrelay/lib/mollie.js:12metadata{accountId, product, plan, interval}. No email while the recurring layer is offrelay/relay.js:5439-5445,relay/lib/billing-recurring.js:123relay/envelope.js:30-31BILLING_MODEis unsetrelay/relay.js:5406,5439,relay/lib/mollie.js:45-55POST /v2/sign-dpais public and checks a name, an organisation, an email and a rate limitrelay/relay.js:187,195,3585-3620/dpatakesfrontend/dpa.htmlthroughoutfrontend/dpa.html:264,271/aboutand/parasignname a Simple Electronic Signaturefrontend/about.html:254,frontend/parasign.html:212scripts/access-log-visitors.mjsrelay/envelope.js:30-31relay/relay.js:1727/dpaalready saidrelay/lib/ct-hash.jsps_free_usesfor a "10/day limit",pm_docs_keyfrontend/js/parashare.page.js,js/status.inline1.js,sign-flow.js,js/dashboard.jslog_formatstarts with$remote_addrdeploy/nginx-selfhost.conf:37,45,102SECURITY.md:42,44,50,54,55/privacysay Nuremberg NBG1frontend/dpa.html:225,263,270One code comment as well:
deploy/nginx-paramant-live.confcalled Bunny.net "the edge". The A record points straight at this machine, so it is DNS and nothing else.Tests
Blocks 23 to 35 in
tests/site-claims.test.mjs, in function scope, node builtins only, in the root integration job. Block 5 was extended to cover Business and/terms. Block 23 now compares the Additional Use Grant, the Licensor and the Licensed Work word for word acrossLICENSE,deploy/LICENSEand the box on/license, not only the Change Date and the Change License. Block 25 also forbids the "No US entity" denial while the DPA table names a US row. Block 26 now pins the envelope retention on/dpaas well.Block 31 calls
mollie.billingStance()rather than pattern-matching it, so the stance the pages describe is asserted against the function that decides it.Sabotage matrix, every mutation applied and reverted, source side and page side for each new pin:
LICENSEChange Date 2029 to 2028LICENSEgrant: 5 API keys to 6deploy/LICENSELicensed Work renamedlicense.htmlbox: "a single self-hosted deployment" to "two"terms.htmlback to "non-commercial"tiers.jscommunitymax_views1 to 2/dpatablepress.htmlback to "No US entity in the chain"/dpaResend row from US to EUenvelope.jsDEFAULT_TTL_DAYS30 to 31/dpacapsule row back to "may persist until envelope expiry"relay.jsRECEIPT_TTL_MS15 to 20 minutesct-hash.jssha3-256tosha256<code>key from the/privacylistjs/dashboard.js$remote_addrfrom the self-host log formatrelay.jsmetadata keyplantotierrelay.jsadds an email field to the checkout payloadmollie.jsrecurringtrue by default/dpaMollie column back to the email claim/termsback to "billing is not yet live"/v2/sign-dpahandler gated on!keyData/privacyback to "For Pro and Enterprise customers"/audit-log-exportback to "relay does not process personal data"SECURITY.mdauditd 49 to 48/dpa114 checks to 113sign.htmldrops the SES sentence/privacydrops the log section/privacyback to "processed only transiently"maskIpForLogrenamed inadmin/lib/log-redact.js/privacyclaims raw admin log lines again/privacyback to volume-only for the audit logdocs//pricinggates the mappings again/pressdenies the timer again/presskeeps a stale ceilingview_ttl_mschangedLICENSE/index/pricingor/parasendFull battery on this branch, rebased on
origin/mainat #369:tests/static-sanity.sheleven checks pass,scripts/check-test-declarations.sh114 suites clean, root integration suites 194 of 197 with 2 skips,relay/test/pricing-page.test.jsgreen, eslint clean, cache-bust and csp-inline clean. The one failure istests/heartbeat-lib.test.mjs, which cannot resolve@noble/post-quantumin this environment; it fails the same way onorigin/mainbefore any change here.Round three, what the reviewer caught
Four of the five were pages this pull request had already touched, which is the uncomfortable part: a row it wrote itself said the audit log was capped by volume and not by time, and
admin/lib/audit.jstrims by age too. Understating a retention is not carefulness, it is being wrong in the flattering direction. All five are corrected and pinned, and one of the pins found a fifth page nobody had looked at.Left alone on purpose:
/parasendlists "IEC 62443 / NIS2 / NEN 7510 documentation as input for your own compliance process, not third-party certification" in its Enterprise card. That sentence is true, carries its own caveat, and is pinned verbatim byrelay/test/pricing-page.test.js:704, so it stays as it is. The fifteen pages calling burn-on-read universal are still a batch of their own; only the outright denial of the timer on/pressis fixed here, because a denial can be swept in one line and a rewrite of fifteen pages cannot.One block that changed under us
Block 35 originally had two halves: the edge access log, and three lines in
admin/server.jsthat wrote a full client IP with one of them carrying the account email. #374 landed while this branch was open and masked all three at the call site, so on the merge commit the admin count was zero and CI was right to go red:/privacywas describing a system that no longer exists.The page now says what the code does. An address is truncated to its network before it is written and an email keeps one character and its domain, so the container log holds neither in full. The admin entry stays on the page as contrast, not as the finding.
The block keeps only what this pull request adds: the edge access log, pinned to
scripts/access-log-visitors.mjs. The admin claim belongs to row 9 andadmin/test/log-redact.test.js, which pin it at the source; a second, weaker copy here would only rot again. What is left of that half is a guard, that/privacymay not go back to describing raw lines and that the masking it describes still exists.One note on the style guard
LICENSEis unchanged frommain, down to the two em-dashes in the Additional Use Grant. It decides what anyone may do with this software, so rewriting it to satisfy a punctuation rule is a decision for the licensor and not for a script inscripts/. An earlier commit on this branch did exactly that and has been reverted.scripts/check-commit-style.shcarries the exception instead, and only for rule 1.LICENSEanddeploy/LICENSEare exempt on path. On/licensethe exempt region is the block betweenLICENCE-VERBATIM-STARTandLICENCE-VERBATIM-END, applied per line number, so a dash added anywhere else on that page still fails. Emoji and AI attribution stay forbidden everywhere including the licence text, and a commit message with a dash is still refused even when it edits the licence. All three of those were checked by trying them.The exemption buys freedom from the punctuation rule and none at all from the test: block 23 still compares the Additional Use Grant, the Licensor and the Licensed Work word for word across
LICENSE,deploy/LICENSEand the box.Out of scope, next batch
Fifteen pages state burn-on-read as a universal property of the service: index, parasend, parashare, ontvang, press, docs, architecture, security, pricing, privacy, vs, audit-log-export and the three help pages.
tiers.jsgives Pro 10 reads per link, Business 25 and Enterprise 100, and/pricingsells exactly that. Row 24 settles the paragraph on/privacy; the rest still say it flat. That is one sweep over fifteen pages rather than a legal-text correction, so it gets its own pull request./press:266"not on a timer" belongs to that batch too.Decisions for you, not for a test
The first version of this list had twenty items and nine of them were not decisions at all: they were facts the code already settled, or contradictions inside this repository. Those are fixed above and pinned. Twelve remain, and each one carries the text that is live today as its default. Nothing waits on you; if you never answer, the default stands.
Processor agreement template
/dpasays German law and the German courts,/termssays Dutch law and the Dutch courts, same company. Default in force: both texts as written./dpais "effective 1 January 2025" and the signature record writesversion: '2025-01-01'(frontend/js/dpa.inline1.js:28);/termsis version 1 of 25 August 2026. Default in force: both as written, and the signature record keeps stamping 2025-01-01./dpapromises deletion of all personal data within 30 days after termination and adds that the CT log may be retained. Pruning is FIFO at 10,000 entries (relay/lib/ct-window.js:55) and file rotation at 100 MB keeping one generation (relay/relay.js:341). Default in force: the sentence as written, with the CT log carve-out./privacy, absent from the/dpatable./dpapromises 14 days notice before a sub-processor change. Adding the Mollie row is a change to that list on paper, even though the code has called Mollie all along. Default in force: the row is added and no notice was sent./dpaandSECURITY.mdquote the same figures, and that is all it does: nothing in this repository verifies the host is in that state. Default in force: the row stays, equal toSECURITY.md.Cookie text
/privacyand/rulessay one strictly necessary session cookie and no trackers, and that matches the code:admin/server.jssetsparamant_user_sessionand nothing else. Default in force: no cookie page.eIDAS beyond SES
/pricing:559says "QES is coming via an EU QTSP partner". No QTSP is named anywhere in the repository. Default in force: the sentence stays, undated.docs/ot-guide.md:221is an IEC 62443 requirement table anddocs/dicom-guide.md:11-17,190-198a NEN 7510 table, and both are served fromfrontend/docs/where a Community account reads them for nothing. What was false is the paid-tier framing. eIDAS is the one with no mapping document. Now row 36 and pinned in three directions, so there is nothing here left for you to decide.Operator calls, not legal ones
:8090block. Overtaken by Admin logs: no full client IP, no plaintext address, and the :8090 gateway written down #374, which gave itaccess_log offin the repo conf and wrote the open question up in full indocs/site-claims.md:location /dicom/reaches afly.devhost through it, withresolver 8.8.8.8, and neither party is on a sub-processor list. Read that row, not this line. Default in force: the route stays.deploy/paramant-admin.py.SECURITY.mdcalls it an operator-configured legacy path. It is on no sub-processor list. Default in force: the client stays and the list stays as it is.Coordination
Rebased twice more, on #375 and then on #374, #376, #377 and #378. #374 is the one that mattered: it changed the code half of block 35 out from under it, which is written up above. Nothing else on this branch overlapped.
Coordination (earlier)
Rebased on
origin/mainafter #369 merged. The two files both branches touched are resolved: the "Burned on read" row indocs/site-claims.mdkeeps this PR's note with #369's page namerules, and the Jurisdiction table infrontend/security.htmlkeeps #369's "exact 5 MB for the ParaSend web app" next to this PR's self-host access-log sentence.