Repository navigation
ci: meet the app standards quality gates - #8
Merged
Merged
Conversation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SvdEeMiewirZ3ifFoUix5F
The app targets Frappe 16 only, so Web Form and Email Template are always registered with extend_doctype_class. EsignWebForm subclasses the stock WebForm unconditionally; the v15 branch that inherited from payments' PaymentWebForm and the override_doctype_class registration are gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SvdEeMiewirZ3ifFoUix5F
- get_print_html set frappe.flags.ignore_print_permissions before validate_esign_key, so without a key the fallback print permission check was skipped and any visitor could read any document's print HTML. The key or the permission check now runs first. - accept updated an existing document with ignore_permissions whenever no key was sent. An update now needs the share key or web form write permission, through has_web_form_permission, as frappe's own accept does. - send_esign_email and get_esign_link (called with a document reference) mint a share key for the document, so they now need the document's email permission, as frappe's email dialog does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SvdEeMiewirZ3ifFoUix5F
frappe-manual-commit (2): - attach_print_to_document committed mid-job so the signed PDF's File row was saved before the Communication touched the document. It now ends after the File is attached and queues a second job, record_signature, with enqueue_after_commit: the queue starts it once the first job's transaction has committed, which keeps the same boundary without a manual commit. The request mocking both jobs need is one context manager, _job_request. - download_signed_pdf committed the new cloud storage sharing link before redirecting. It sets frappe.local.flags.commit instead, so the request commits at its end, before the response is sent. It also redirected with the raw UUID while storing the UUID's top 64 bits, so the first download of a file without a link went to a key that matched nothing; the stored key is now the one in the redirect. frappe-ssti (2): the notification's subject and message are rendered from a System Manager-authored Notification, as frappe's own Notification does; the two calls carry nosemgrep with that reason. The context is built as Notification.send builds it (get_context(doc) was computed and then thrown away), and the audit trail values appended to the email are HTML-escaped, since the user agent and the email parameter come from the signer's request. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SvdEeMiewirZ3ifFoUix5F
- Read WebFormField.fieldname and Notification.set_property_after_alert through get(): frappe types both as Literal[None], so ty treated every branch that used them as unreachable. - esign_update_field is typed as the string it holds; its Select options are the target doctype's fields, set at runtime. - The request snapshot for the signing job is built key by key instead of looping over a TypedDict with str keys. - The image upload check in accept read `"data:" and "base64" in value`, where "data:" was always true; it now checks for both substrings. - Drop two unused type: ignore comments, and the blanket one on the attachments lookup (frappe.desk.form.load is imported as a module so a patched get_attachments is still the one called). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SvdEeMiewirZ3ifFoUix5F
get_context, get_print_html and download_signed_pdf set doc.flags.ignore_permissions before validate_esign_key. Without a key, that falls back to frappe's print permission check, whose website permission step passes any document carrying the flag, so a visitor with no key could open the signing page and print HTML of any document, and a logged-in user without read permission could download its signed PDF. The flag is now set only after the key or the permission check has passed (and not at all where nothing needs it). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SvdEeMiewirZ3ifFoUix5F
- get_esign_link annotated doc as Document, so frappe v16's argument type
check refused the JSON or dict the desk sends (the link preview in the
"Send for eSign" dialog). It now accepts all three, as its body always did.
- The link joined get_url() and the route without a slash
("https://hostesign-form/..."), unless the configured host name ended in
one. It now always has exactly one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SvdEeMiewirZ3ifFoUix5F
77 tests on a submittable test doctype (created by the tests), an eSign web form on it, and requests built the way frappe builds them: - the signing page for a Guest with and without a key, completed documents, force-mobile and the stock web form when eSign is off; - validate_esign_key, has_web_form_permission (key from the form or the referrer), get_print_html and download_signed_pdf (private, public and cloud storage files) with valid, wrong and missing keys; - accept: signing as a Guest with a key, refusals without one or without permission, the anonymous form's switch to Guest and back (also when saving fails), submit on response, the update field, attachments, new documents, and the audit data and request snapshot it queues; - both signing jobs: the signed PDF, the Communication and its copy of the PDF, cloud storage association, and the notification with the escaped audit trail, its property update and every misconfiguration it skips; - can_esign, send_esign_email, the timeline cards, the Email Template extension, the Jinja method and the install, migrate and uninstall hooks. Coverage is 97.9%; fail_under goes from 0 to 80, the profile's target. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SvdEeMiewirZ3ifFoUix5F
…s a bundle Compiled code (the desk, control and web bundles and the controls they import) is now TypeScript, checked by the managed tsconfig.browser.json with the frappe-types strict preset. The Web Form doctype script stays JavaScript with JSDoc, checked by tsconfig.desk.json against types/doctypes.d.ts (frappe-types gen-registry: Web Form and Email Template with esign's custom fields). - controls/base.ts types frappe.ui.form.ControlData as the ControlInput subclass it is at runtime; frappe-types declares it as the base Control. Fields the controls set while frappe's constructor runs are `declare`d, so the compiled classes never re-initialise them. - types/esign.augment.d.ts declares what esign adds to frappe (ControlUpload, ControlFontSelect, frappe.esign_context, frappe.is_completed) and to the page; types/frappe-modules.d.ts declares frappe's web form modules the web bundle imports, now by "frappe/public/js/…" (resolved from the bench's apps directory) instead of five levels of "../". - frappe keys a bundle in assets.json by its source file name, so hooks.py and the eSign page now name esign.desk.bundle.ts, esign.control.bundle.ts and esign.web.bundle.ts. - The signature fonts are a CSS entry (esign-fonts.entry.css) that Vite builds to dist/css/esign-fonts.bundle.<hash>.css with a manifest, and scripts/vite-register.mjs registers it as esign-fonts.bundle.css. hooks.py and the eSign page use that name instead of the unhashed /assets/esign/dist/esign-fonts.css path, so a release busts the cache. - web_form.js calls its two helpers directly instead of through frm.events. - Small runtime changes, all deliberate: the upload control's button now reads "Change" after a file is chosen (the old code set textContent on a jQuery object, a no-op); the debug console lines on the eSign page are gone. - @types/hast is declared (the controls build hast trees). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SvdEeMiewirZ3ifFoUix5F
…age and accept The update guard in accept refused a desk user whose role may write the doctype but who is not the owner and has no key; frappe's own accept saves for them with permission checks on. accept now allows that case too. The signing page without a key now also admits the users the web form's own rules admit (owner, website permission, document permissions), so it agrees with accept. Adds tests for both, and for download_signed_pdf refusing a logged-in user without print permission when no key is given. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SvdEeMiewirZ3ifFoUix5F
The desk timeline template prints its data with {{ }}, which is raw output in
frappe's microtemplates, and several of the values come from the signer's
request (user agent, the email parameter, form-derived names). A Guest with a
share key could store markup that ran for every desk user opening the timeline.
get_timeline_content now HTML-escapes every value it passes to the card, and the
email parameter is recorded only when it is a single valid address.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SvdEeMiewirZ3ifFoUix5F
`bench build` keys assets.json by an entry's source name, but `bench build --using-cached` (used by benches that install prebuilt assets) keys by the built file's name, which is always .js. With .ts entries the desk and the signing page asked for esign.*.bundle.ts there and got a 404. The esbuild entries are now small .js files that import the TypeScript modules, and hooks.py and the signing page name esign.*.bundle.js, which both build paths produce. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SvdEeMiewirZ3ifFoUix5F
…wnload The signing page admits a logged-in user without a key when the eSign web form's own rules do (owner, website permission, document permissions), but get_print_html and download_signed_pdf still required print permission, so such an owner got the page with a failed preview and a refused signed-PDF link. Both endpoints now take an optional web_form and go through one helper, validate_esign_access, which the page uses as well: without a key, a logged-in user the named eSign web form (for the same doctype) admits is let in; everyone else falls back to the share-key and print-permission checks as before. Guests still need a key. The page passes its web form name to both. Adds tests calling both endpoints as the keyless owner, as a non-owner, with a missing or non-eSign web form, and as Guest. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SvdEeMiewirZ3ifFoUix5F
ssort's order in a clean checkout puts the helper above validate_esign_access, which calls it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SvdEeMiewirZ3ifFoUix5F
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.
Summary
Brings esign up to the app standards quality gates: registry semgrep clean with an empty baseline, ty clean with no ignores, strict TypeScript for the compiled bundles and JSDoc-checked desk JS, and tests from zero (90 tests, coverage 97.9%, floor 0 → 80) with a complete testmap and passing composition check. The v15 override path is gone.
Writing the tests turned up four access-control holes in the signing endpoints, and a few plain bugs. They are fixed here in separate
fix:commits, each called out below. Please read the Review guide before merging: these are deliberate behaviour changes on a security-sensitive app.What changed
One commit per concern:
chore: ignore the app standards formatting commit in blame: adds style: adopt the app standards (managed config and formatting) #7's squash0f2bacb(a squash, one parent) to.git-blame-ignore-revswithfrappe-nix compat --add-blame-ignore.refactor: drop the Frappe v15 controller override path:hooks.pyalways registersextend_doctype_classand no longer imports frappe.EsignWebFormsubclasses the stockWebFormonly, and thePaymentWebFormbranch is gone.fix: check access before rendering, updating or sharing a document:get_print_htmlsetfrappe.flags.ignore_print_permissionsbeforevalidate_esign_key. Without a key, the fallback print-permission check was then skipped, so any visitor could fetch the print HTML of any document of any doctype. Validation now runs first.acceptupdated an existing document withignore_permissionswhenever no key was sent. Updates now need the share key, web form write permission (has_web_form_permission: owner, website permission,apply_document_permissions) or DocType write permission (frappe.has_permission). Those are the three cases in which frappe's ownacceptsaves; it saves the last one with permission checks on.send_esign_emailandget_esign_link(when given a document reference from a request) mint a share key, so they now need the document'semailpermission. That is the same check frappe's email dialog makes. Server-side callers (Jinja, Python) pass aDocumentand are unchanged.fix: clear the registry's blocking semgrep findings:frappe-manual-commit×2:attach_print_to_documentcommitted in the middle of the job. It now attaches the PDF and queues a second job,record_signature, withenqueue_after_commit, so the Communication is still written after the File has been committed.download_signed_pdfnow setsfrappe.local.flags.commitinstead of committing itself. That code path also had a bug: it redirected to the raw UUID while storinguuid.int >> 64, so a file's first download always missed. It now stores and redirects with the same key, in cloud_storage's own format.frappe-ssti×2: the notification subject and message come from a System Manager-authored Notification, which frappe's own Notification renders the same way. Each call carries# nosemgrepwith that reason.Notification.sendbuilds it. Before,get_context(doc)was computed and then overwritten.escape the signer's audit valuesbelow.)File.get_content()decodes binary PDFs as text and would corrupt them.fix: make ty clean:WebFormField.fieldnameandNotification.set_property_after_alertare read throughget(). frappe types both asLiteral[None], so ty treated whole branches as unreachable.esign_update_fieldis typedDF.Data."data:" and "base64" in value(where"data:"was always true) now tests both substrings.type: ignorecomments are gone; noty: ignoreremains.fix: validate the share key before marking a document permission-free:get_context,get_print_htmlanddownload_signed_pdfsetdoc.flags.ignore_permissionsbefore validating. frappe's website permission check (part of the no-key fallback) passes any document that carries that flag. So without a key, a Guest could open the signing page of any document of an eSign-enabled doctype, and a logged-in user could download any signed PDF. The flag is now set only after validation.fix: build working eSign links from a document reference:get_esign_linkwas annotateddoc: Document, so frappe v16's argument validation rejected the JSON the desk sends, and the link preview in "Send for eSign" never loaded.get_url()and the route without a slash (http://host:8000esign-form/…). It now always has exactly one.Review fixes (
fix:commits on top):fix: let owners and users with write permission through the signing page and accept: theacceptguard above now also admits DocType write permission, so a desk user who may write the document but is not its owner can sign without?key=, as before this PR. The signing page without a key also admits whoever the web form's own rules admit (owner, website permission, document permissions), so a portal owner can open their own record from the web form list, asacceptalready allowed. New tests: a non-owner System Manager and an owning Website User updating without a key, the owner opening the page, a user with no permission refused, anddownload_signed_pdfrefusing a logged-in user without print permission (that last fix had no regression test before) while a user with print permission gets the file.fix: escape the signer's audit values on the timeline card: the desk timeline template prints its data with{{ }}, which is raw output in frappe's microtemplates. A Guest with a share key could sign with a crafted User-Agent oremailparameter and store markup that ran for every desk user opening the timeline (this predates the PR).get_timeline_contentnow HTML-escapes every value it hands the card, and theemailparameter is recorded only when it is a single valid address (otherwise the form's email field or the logged-in user is used, as when the parameter is absent).fix: name the bundle entries .js so prebuilt assets resolve them: see the asset names item under the TypeScript commit.fix: apply the keyless web form rule to the preview and signed-PDF download(review round 2): the page admitted a keyless owner, butget_print_htmlanddownload_signed_pdfstill required print permission, so a Website User owner got the page with "Failed to load document preview." and a refused signed-PDF link. One helper,validate_esign_access, now decides for all three: without a key, a logged-in user whom the named eSign web form (same doctype) admits by its own rules is let in; everyone else gets the share-key / print-permission check as before, and Guests still need a key. Both endpoints take an optionalweb_formargument, which the page passes (frappe.esign_context.web_formand the signed-PDF URL). New tests call both endpoints as the keyless owner, a non-owner, with a missing or non-eSign web form, and as Guest.test: cover the signing flow, its key checks and the desk hooks: 78 tests on a test-created submittable doctype and an eSign web form. They use requests built the way frappe builds them (set_request,get_response_content). Coverage:accept, including the anonymous form's switch to Guest and back, also when saving fails;send_esign_email,can_esignand its hooks, the timeline cards, the Email Template extension, and the Jinja and install/migrate/uninstall hooks.fail_undergoes from 0 to 80, the profile target the ratchet asks for.refactor: type the bundles in strict TypeScript and build the fonts as a bundle:.ts, checked by the managedtsconfig.browser.jsonwith the frappe-types strict preset.web_form.js(the Web Formdoctype_js) stays JS with JSDoc, checked bytsconfig.desk.jsonagainsttypes/doctypes.d.ts(frappe-types gen-registry).controls/base.tstypesControlDataas theControlInputsubclass it is at runtime. Instance fields set while frappe's control constructor runs aredeclared, so the compiled classes never reset them. I checked the compiled output.types/esign.augment.d.tsholds the app-owned members (// app-owned:).types/frappe-modules.d.tsdeclares frappe's web form modules, now imported asfrappe/public/js/…(esbuild resolves these from the apps directory) instead of../../../../../frappe/….esign.*.bundle.js. The esbuild entries are small.jsfiles (esign.desk.bundle.js,esign.control.bundle.js,web/esign.web.bundle.js) that import the TypeScript modules (esign_desk.ts,esign_controls.ts,web/esign_web.ts).bench buildkeysassets.jsonby the entry's source name, whilebench build --using-cachedkeys it by the built file's name with the hash stripped, which is always.js; only a.jsentry gets the same key from both. (The first revision named the entries.ts, which 404s on benches that install prebuilt assets.)esign/public/css/esign-fonts.entry.css→ Vite →dist/css/esign-fonts.bundle.<hash>.csswith a manifest.scripts/vite-register.mjsregisters it asesign-fonts.bundle.css, which replaces the unhashed/assets/esign/dist/esign-fonts.csspath, so a release now busts the cache.@types/hastis declared.Verification
Everything below ran inside the nix dev shell at the head of this branch unless noted:
frappe-test --ci --recreate-siteat the head → 90 tests OK, coverage 97.9%,fail_under80; ty and nix-lint stages pass. Testmap: "15 target(s), every one tested or exempt (0 exempt)". Composition: "2 controller(s) compose in both orders". Exit 0.frappe-nix sync --check→ clean.frappe-nix compat→ exit 0.yarn -s typecheck:py(ty,error-on-warning) →All checks passed!. The app has noty: ignoreortype: ignorecomments.yarn run check(oxfmt, oxlint, stylelint,tsc --buildover the browser and desk projects) → exit 0, with no oxlint warnings.frappe-listing check→ every check passes, including L7 registry semgrep with an empty baseline (18 advisory notes remain: missing type hints on whitelisted args, and guest-whitelisted endpoints flagged for review) and all pilot L9 checks.MYSQLCLIENT_CFLAGS/MYSQLCLIENT_LDFLAGSfrom nixpkgs'mariadb-connector-c. A frappe-nix fix is in flight.bench build --app esign→esign.{desk,control,web}.bundle.jsregistered under their.jskeys, pointing atdist/js/esign.*.bundle.<hash>.js(the names--using-cachedkeys the same way);vite-register: esign: esign-fonts.bundle.css -> /assets/esign/dist/css/esign-fonts.bundle.<hash>.css.uv run --python 3.14 --frozen --project tools prek run --all-files, yarn from nixpkgs): every hook passes,ssortandstatic_analysisincluded, exit 0. (At 5c0da14 the clean-clone ssort wanted_get_esign_web_formabove its caller; 623a73a applies exactly that order, whichssortalso accepts inside the dev shell.)SKIP=static_analysis. That hook scans the in-repo bench there; a frappe-nix fix is in flight.download_signed_pdf's old flag order put back), the new tests fail:test_user_with_doctype_write_permission_updates_without_key,test_owner_without_key_gets_the_signing_page,test_user_without_key_needs_print_permission,test_signer_email_resolutionandtest_signer_controlled_values_are_escaped. With the fixes, all pass.git grepand the commit-message check for the internal codename print nothing.Review guide
Start with the three
fix:commits that change access (check access…,validate the share key…, and theacceptguard in the first one). Things that worked before and are now refused, on purpose:/<route>/<name>/editor callingget_print_html/download_signed_pdf;acceptwithout its key, unless the user has web form write permission (owner, website permission orapply_document_permissions) or DocType write permission;apply_document_permissions);emailpermission on the document sending an eSign request or fetching a link for it.Signing with the emailed link, desk users with write (for
accept) or read/print (for the page) permission, and owners are unaffected: an owner without a key gets the page, its preview and the signed-PDF download (through the page'sweb_formargument; calling the endpoints without it still needs print permission). One exception for keyless owners with no print permission: the page's "Download PDF" button (before signing) uses frappe's owndownload_pdf, which this app does not control; it refused them on develop too. The timeline card now shows escaped text, so any markup a signer put in the user agent or email shows as text. Before deploying, check that nobody relies on opening eSign pages without the?key=link.The background job split (
attach_print_to_document→record_signature). The timeline entry and notification now arrive in a second short job, after the first job's commit. If the worker died between the two, the PDF would be attached without its Communication. The old code committed at the same point, so this window existed before too.Asset names. The JS bundles keep their
esign.*.bundle.jsnames; the fonts are now looked up asesign-fonts.bundle.css. A bench must runbench build(which runs the app'syarn buildandvite-register) after deploying, or the fonts don't load.Manual check worth doing, which I did not do in a browser:
Smaller deliberate changes:
console.debuglines are gone.web_form.jscalls its helpers directly instead of throughfrm.events.Not done / follow-ups
esign_webforms: separate PR, as planned.web_form.pyrefactor (its ownacceptcopy, the Guest session switch, and the request mock for jobs): its own reviewed PR. The tests here pin the current behaviour, so that PR can be checked against them.get_esign_email_templatesusesfrappe.get_all, so any logged-in user can list eSign email templates. Nothing in the app calls it.LinkExpired(a 500) fromhas_web_form_permissioninstead of a 403.acceptandget_print_htmlsetfrappe.local.message_log = None, which breaks any latermsgprintin the same request.acceptleaves two File rows (esign's own, plus the one frappe v16 records for attach fields on save).send_esign_email,accept,get_esign_web_forms). Adding hints switches on frappe's argument validation, so it needs a reviewed change;get_esign_linkshows what happens when a hint is wrong.static_analysishook scans the dev bench inside the shell;frappe-types gen-doctypes, while frappe-types 16.5.0 ships the generator asgen-registry.🤖 Generated with Claude Code
https://claude.ai/code/session_01SvdEeMiewirZ3ifFoUix5F