Repository navigation
fix(agreements): certificate says "via signing link", versioned so signed certificates never change (#10513) - #10711
Conversation
…gned certificates never change (#10513) The signature certificate derived "Electronically accepted via emailed link" from the public-token jti, which is also set when the technician copied the link and shared it another way. New signatures now print the neutral "via signing link". The certificate is derived on demand, so changing the wording in place would rewrite the certificate of every agreement already signed. The wording is versioned instead: contract_documents.certificate_version is stamped at signing (freezeSignedAgreement writes CURRENT_CERTIFICATE_VERSION = 2), and the append-only trigger freezes it. NULL = v1 = every agreement signed before this change, which keeps "via emailed link" byte for byte. No backfill and no trigger change: the column is added without a default, then given DEFAULT 2 for future inserts only (covers an older instance inserting mid-rollout). A CHECK keeps NULL the only spelling of v1; an unknown stored version fails loudly rather than printing another version's wording. Export policy: certificate_version is included. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Strix Security ReviewWarning This pull request has 274 commits after the last Strix review ( ✅ No open security findings. PR summary: Changes the signature certificate's token-link wording to "Electronically accepted via signing link" and makes the wording versioned: a new `contract_documents.certificate_version` column is stamped at signing (NULL = v1, new signatures = v2) so certificates of agreements already signed keep rendering the old text. Adds the migration, a version module, the renderer/loader plumbing, the insert-site stamp, and the export-policy entry. Review summaryThis PR changes the wording of the signature certificate's "How" line for token-link acceptances and, to avoid rewriting already-signed evidence, adds a I reviewed every changed file, tracing the new column from the migration and schema through the single insert site, the derived-certificate loader/renderer, the export policy, and all certificate download and signed-copy email call sites. No security vulnerability was confirmed. Key points verified: One candidate — the unbounded Ruled out (1)
Review 1 on this PR · Reviewed by Strix |
Deploying breeze with
|
| Latest commit: |
f5bc794
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://614da1a7.breeze-9te.pages.dev |
| Branch Preview URL: | https://fix-10513-certificate-signin.breeze-9te.pages.dev |
…lt); document rollback + explicit-stamp rule (#10513 review) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Review run: one independent round, two Sonnet reviewers: Findings: 1 critical, raised by the test analyzer and addressed in 38d037d:
The code reviewer found 0 significant issues. I addressed two of its low notes in 38d037d:
Left as is: a 0 findings outstanding. Tests:
Merge order: #10654 first, then rebase this PR. The rebase has one one-line conflict in Status: review-clean, awaiting maintainer merge. |
…ract-documents-certificate-version → 2027-01-17-188000-contract-documents-certificate-version;) Main's newest migration is 2027-01-17-140000 (#10719); the queue's Check Migrations job rejects anything that sorts before it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…version migration after main's newest Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Main merged; unshipped migration moved to |
…-01-18-170000) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Main merged; unshipped migration moved to |
…-01-18-183000+) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Main merged more migrations; unshipped migration moved to |
Resolve tenantExportPolicyRegistry.ts: contract_documents.included keeps both template_name_snapshot (#10654) and certificate_version (this PR). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Merged Conflict: Tests: these were run locally. The vitest runs for the tenantExportPolicyRegistry tests, agreementCertificate and contractDocumentService passed: 15 files, 170 tests. The tenantExportPolicy*, tenantExport and admin tenantExport route tests passed: 19 files, 130 tests. The DB-backed integration suites (orgCascadeFkOnDelete, tenantExportErasureRoundtrip, contractDocumentsCertificateVersion, portalAgreements) were not run locally, so CI's Integration Tests will cover them. |
…label; Send dialog expiry no longer jumps (LanternOps#10294) (LanternOps#10654) ## Summary - **The internal Label no longer reaches the customer.** The Send dialog's optional Label was the agreement's name in the portal list, the dashboard "Awaiting your signature" card, the portal signed-agreements list, the request and reminder emails, the signed-copy email, and the certificate (both its "Agreement" row and the attachment filename). The detail and public signing pages showed the template name, so the customer saw two names for one document. Every customer-facing surface now uses the template name. The portal/public detail DTO no longer carries `request.label`. The Label field now has the hint "Internal only. Customers see the template name." Staff surfaces are unchanged: the web agreement lists, the outcome notice to the sender, and the contract-document source note still show the label. - **The Send dialog's expiry no longer jumps.** The date used to start at a hard-coded today + 30 and then change to the partner default (LanternOps#10235) once an org was picked. The default is resolved per org, because a system user can pick orgs from different partners, so the date now stays blank until that default is known. A hint says a blank date uses the default. This needs no API change: `createAgreementRequest` already resolves a blank `expiresAt` to the partner default. If the defaults read fails, the date stays blank and the hint now says so. The old hint said "the built-in default is shown". - **The certificate keeps the template name it was signed under.** Before this, the certificate and the other signed views read the live template name, so renaming a template changed the certificate of every agreement already signed from it. Signed evidence must not change. New column `contract_documents.template_name_snapshot` stores the name at signing. `freezeSignedAgreement`, the single insert site, writes it on every path: authored template, uploaded template, external upload, and both quote and request sources. These read the snapshot and use the live name only when it is NULL: - the certificate (its "Agreement" and "Template" rows and the attachment filename) - the portal signed-agreements list - the signed-copy email - the portal and public-link detail view of a signed request The staff `listContractDocuments` list keeps the live name, because staff manage templates under their current names. ## Linked Issues Closes LanternOps#10294 ## Merge Danger **This is now a migration PR.** **Migration:** `apps/api/migrations/2027-01-15-105000-contract-documents-template-name-snapshot.sql`. - It adds the nullable `contract_documents.template_name_snapshot varchar(255)` and backfills it from the current `contract_templates.name`. - It elects system scope first, is idempotent, and reports its row count. There are no partner-export triggers on this table. - The `contract_documents_block_update` append-only trigger freezes every column, so the migration disables that trigger around its NULL-only UPDATE and re-enables it in the same transaction, as the header of `2027-01-06-170000` requires. The trigger function itself is unchanged. Once the column is set, the trigger freezes it like any other evidence column. - **Rename at merge:** the file must sort after the newest migration on main. It does today: main's newest is `2027-01-15-100000`, and this file is slotted before LanternOps#10626 and LanternOps#10711 at `2027-01-15-110000`. Rename it if another migration lands first. **Backfill caveat:** existing rows get the template name as it is when the migration runs. A template renamed *before* this ships keeps its current name on its old certificates, not its original one. The original survives only as the heading drawn into an authored template's frozen PDF. **Rolling deploy:** the column is nullable. An instance still on old code inserts NULL, and readers fall back to the live name. **Export policy:** `template_name_snapshot` is classified `included`. Cascade and org-merge need no change because this is a column on an existing table. **Door:** - The label change is two-way. - The column is additive. A revert of the code leaves an unused column behind. - The backfill is data written under the append-only guard, so it cannot be undone without disabling the guard again. **Blast radius:** - customer-facing agreement naming: portal, emails, certificate - the staff Send dialog's expiry field - one ALTER plus a single UPDATE on `contract_documents` at boot. Adding a nullable column with no default only changes metadata. The UPDATE reuses the TOAST pointers for `pdf_data`, so the PDFs are not rewritten. **Contract change:** - `request.label` is removed from the portal `GET /portal/agreements/:id` and public `GET /agreements/public/:token` payloads. The only consumer was the portal type, which this PR removes, and it never rendered the field. - `name` in `GET /portal/agreements` and `GET /portal/signed-agreements` keeps its shape. It is now always the template name, and for a signed agreement it is the name at signing. **Decision to confirm:** the certificate previously named the agreement by its label, as spec §3 in `2026-10-15-standalone-agreement-signing-spec.md` (line 190) said. The certificate is attached to the customer's signed-copy email and can be downloaded in the portal, so it now uses the template name as it was at signing, including for staff downloads. If staff need the label on the certificate, that needs a staff-only variant in its own PR. ## Overlap - LanternOps#10515 (LanternOps#10199) touches `agreementsPublic.ts`, `portal/agreements.ts` (the `fireAndForget` hunk near line 171), and `PublicAgreementView*`. This PR's hunks are separate: the list mapper near line 230, a fixture line near line 20, and `agreementsPublic.test.ts:269`. `git merge-tree --write-tree` against `fix/10199-agreement-link-signed-state` is clean. - LanternOps#10642 (LanternOps#10290) touches only middleware. `git merge-tree` against `fix/10290-portal-agreement-pdf-frame` is clean. ## Type of Change - [x] Bug fix - [x] Database migration ## Testing - Template-name snapshot follow-up. Every test below failed before the fix and passes after it: - Certificate loader: renaming the template after signing leaves the certificate content byte-for-byte unchanged, and a NULL or blank snapshot falls back to the live name. - `freezeSignedAgreement` writes the snapshot for the authored, uploaded-template, external-upload and quote-source paths. - The portal signed list, the signed-copy email (subject, html and text), the portal detail and the public-link detail of a signed request all show the name at signing. - The export-policy unit test checks that the column is classified. - The integration test `contractDocumentsTemplateNameSnapshot` runs the migration as the table owner with the guard on, against a signed row that already exists. It checks the backfill, that the guard is enabled afterwards, that a re-run is a no-op after a rename, and that rewriting the snapshot is refused (55000). It failed with 55000 before the trigger handling was added. - The `portalAgreements` and `agreementsPublic` integration suites still expected the staff label (`BAA 2027`) as the customer-facing name and attachment filename. Verified red on this branch, then updated to the template name. `agreementsPublic` now also asserts that the real write path stores `template_name_snapshot`. - After the rebase onto main: 45 agreement, contract-document, portal, quote-acceptance and tenant-export-policy integration files pass 289/289. - Local integration runs, single fork: that suite, `contractDocumentsAppendOnly`, `contractDocumentsEvidenceBackfill`, `tenant-export-policy`, `orgMergeRegistry`, `tenantCascade`, `orgCascadeFkOnDelete`, `tenantCascadeErasureBreadth` and `agreementRequestsRls` pass 98/98. `pnpm db:check-drift` reports no drift. The migration guard unit tests (`autoMigrate`, `migrationRlsScope`, `migrationPartnerExportLocks`) pass. API `tsc --build tsconfig.tests.json` with a 12 GB heap exits 0 with 0 errors. - [x] Added new tests. Each failed before the fix and passes after it. The web dialog tests were also re-run against the `origin/main` component: 4 failed. - Portal list: the `GET /agreements` payload has no `label` key, the raw JSON does not contain the label text, and `name` is the template name. The same applies to the detail payload. - Public DTO: `label` is absent. `agreementsPublic` GET asserts the same. - Signed-agreements list, send email, reminder email, and signed-copy email: subject, html, and text never contain the label. - Certificate: `agreement.name` is the template name, and the rendered content omits the label. - Send dialog: the date is blank until the default arrives and then becomes the partner default with no +30 step. With no org picked, the date is blank. A failed read leaves it blank, the hint says so, and the create call carries no `expiresAt`. The Label hint renders. - API: 21 agreement and portal unit test files pass, 525 tests, single fork. Web: `SendAgreementDialog` passes 33/33, and the i18n parity, keyUsage, terminology, coverage and locale suites pass 286/286. Portal agreement components pass 37/37. - `tsc --noEmit` is clean for web and portal. The full API `tsc --build tsconfig.tests.json` exits 0 with a 12 GB heap. - New locale keys `expiryBlankHint` and `labelHint`, plus the reworded `expiryDefaultUnavailable`, are in all 8 locales. ## Checklist - [x] My code follows the project's code style - [x] I have reviewed my own changes - [x] No secrets, credentials, or personal data included - [ ] Settings rule 9: not applicable. This does not touch `pages/settings/**` or add a setting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Todd Hebebrand <todd@lanternops.io> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Todd Hebebrand <todd@pressless.io>
Summary
contract_documents.certificate_versionis stamped at signing, and the append-only trigger then freezes it. NULL = v1: every agreement signed before this ships keeps printing "via emailed link". New signatures get v2.agreement_requests.public_response_jti) and quote-sourced ones (quote_acceptances.acceptance_token_jti).Linked Issues
Closes #10513
Design
services/agreementCertificateVersion.tsdefinesCertificateVersion = 1 | 2,CURRENT_CERTIFICATE_VERSION = 2, andcertificateVersionFromColumn(NULL → 1, 2 → 2; anything else throws).freezeSignedAgreement(the single production insert site) writescertificateVersion: CURRENT_CERTIFICATE_VERSIONexplicitly, on both sources and every path (authored, uploaded template, external upload).howLinestakes the version. Only the token-channel string is keyed by it (SIGNING_LINK_VIA). A test pins that v1 and v2 differ in that one line only. Portal, unknown, on-behalf, upload and unavailable certificates come out identical under both versions.emailed_linktosigning_link, because the jti records that the token link was used, not how it was delivered.2027-01-15-110000-contract-documents-certificate-version.sql:ADD COLUMN IF NOT EXISTS certificate_version smallintwith no default, so existing rows stay NULL = v1.ADD COLUMN … DEFAULT 2would have stamped them v2.SET DEFAULT 2affects future inserts only. It covers an instance still on older code that inserts during the rolling deploy.contract_documents_certificate_version_chk(NULL OR >= 2) keeps NULL the only spelling of v1.certificate_versionisincluded.Overlap with #10654: merge order
Merge #10654 first, then this PR.
2027-01-14-120000-contract-documents-template-name-snapshot.sql. This PR's migration (2027-01-15-110000-…) sorts after it and after main's newest (2027-01-15-100000-…).check-migration-namingwould reject it. I saw that exact violation in a local compose check.git merge-tree --write-tree HEAD origin/fix/10294-agreement-portal-titleshows one textual conflict: the single-linecontract_documentsentry intenantExportPolicyRegistry.ts. Both PRs append a column to the sameincludedarray, so the resolution is the union (…"acceptance_render_locale","template_name_snapshot","certificate_version"]).agreementCertificate.ts,contractDocumentService.tsand both test files.UPDATEoftemplate_name_snapshot. This PR adds a column at INSERT time and touches neither the trigger nor existing rows.portalAgreements.integration.test.ts:185, which expects the portal listnameto be the request label"BAA 2027". fix(agreements): customers see the template name, never the internal label; Send dialog expiry no longer jumps (#10294) #10654 deliberately changes that to the template name and does not touch that file.Merge Danger
Door: one-way, in a narrow sense. The new column is additive and nullable, and a revert of the code alone is safe because old code ignores it. But each certificate rendered for a v2 row says "via signing link" from then on. That is the intended frozen evidence. Rolling back after v2 rows exist would make old code print "via emailed link" for them until this PR is redeployed.
Blast radius: every signed agreement's certified copy (signature certificate page), both the portal/app download and the signed-copy email.
Type of Change
Testing
agreementCertificate.test.ts+contractDocumentService.test.ts+ the new export-policy pin gave 14 failed / 86 passed. After the change: 100/100. Red tests included:freezeSignedAgreementstamps 2 on both sources;certificate_versionis included in the export policy.contractDocumentsCertificateVersion.integration.test.ts(real Postgres, through the real loader), 5/5:certificateVersionFromColumnforced to return 2 for NULL, that suite goes 3 failed / 2 passed.NODE_OPTIONS=--max-old-space-size=12288 npx tsc --build tsconfig.tests.json(apps/api) exit 0.pnpm db:check-drift: no drift.check-migration-naming --against-ref origin/main: OK.Checklist
🤖 Generated with Claude Code