External-signing LOW polish — implementation plan

For agentic workers: subagent-per-task. Each task: implement → cd go && go build ./... && go vet ./... (Go) and/or cd web && npx tsc --noEmit && npx vite build (web); regen npm run gen:api after any OpenAPI edit. Commit locally on main per task. Deploy from REPO ROOT (docker compose -f deploy/docker-compose.yml --env-file deploy/mekari.env up -d --build obscura web) in batches; after each deploy assert /me enabled_modules == [correspondence,watermarking,ai,esign]. NEVER go test.

Goal: Close the 11 LOW-severity findings from review weyebd19a in the external-party signing feature.

Source of truth: each task cites the finding’s file:line + fix. Order minimizes file conflicts (backend first, then frontend grouped by file).


Task 1 — Public sign routes get an esign module gate (LOW #2)

Files: Modify go/internal/httpapi/server.go (the /public/sign/{token}* route group, registered BEFORE the Authenticator group).
- [ ] Wrap each public sign route (or the group) with s.requireModule("esign") — it checks the server’s licensed modules and needs no Principal. If the license is later revoked, already-issued links stop signing/previewing (matching every authed esign route).
- [ ] Verify requireModule works outside the auth group (no Principal deref). go build ./... && go vet ./....
- [ ] Commit: fix(esign): gate public signing routes on the esign module (LOW #2)

Task 2 — signed_ip / rate-limit key use the real client IP behind a proxy (LOW #1)

Files: Modify go/internal/httpapi/handlers_public_sign.go (clientIP).
- [ ] Make clientIP(r) prefer the left-most X-Forwarded-For entry, then X-Real-IP, then r.RemoteAddr host. Add a brief comment that XFF is trusted only because the on-prem deploy fronts the app with its own nginx (note the spoofability tradeoff). This improves both the signed_ip evidence (used in PublicSubmitOTP) and the limiter key.
- [ ] go build ./... && go vet ./....
- [ ] Commit: fix(esign): derive client IP from X-Forwarded-For for signed_ip + rate limit (LOW #1)

Task 3 — Invite shows the real document title (LOW #4)

Files: Modify go/internal/esign/app/service.go (CreateSignEnvelope InviteRequest, ResendExternalInvite, the EnvelopeSignerInput/signer rows), go/internal/httpapi/handlers_esign.go (buildEnvelope passes the title), migration go/migrations/00066_*.sql (add doc_title text NOT NULL DEFAULT '' to esign_sign_envelopes), go/internal/esign/adapters/envelope_pg.go (insert/scan + signEnvelopeColumns), go/internal/esign/domain/envelope.go (SignEnvelope.DocTitle).
- [ ] Add doc_title to the envelope (migration + domain + repo insert/scan). Thread the real title from buildEnvelope (it already has doc.Title) into CreateSignEnvelope, persist it on the envelope, and use env.DocTitle (fallback "a document") in BOTH the initial invite and ResendExternalInvite.
- [ ] go build ./... && go vet ./....
- [ ] Commit: fix(esign): real document title in external invites (LOW #4)

Task 4 — Distinct wrong-OTP vs expired-OTP error (LOW #6, backend half)

Files: Modify go/internal/esign/adapters/sealer_mekari.go (the OTP-submit error path / mekariErrorMessage or SubmitOTP) — Mekari returns {"otp":["OTP has expired or not found"]} for expired and a different message for a wrong code.
- [ ] Map an expired/not-found OTP to a distinct kernel.Error code esign.otp.expired (vs a generic esign.otp.invalid for a wrong code) so the frontend can show an actionable message. Keep it best-effort string-matching on Mekari’s param message.
- [ ] go build ./... && go vet ./....
- [ ] Commit: fix(esign): distinguish expired vs wrong OTP from the provider (LOW #6 backend)

Task 5 — External OTP submit gets a per-signer atomic claim (LOW #3)

Files: Modify go/internal/esign/app/service.go (SubmitSignerOTPByToken), go/internal/esign/adapters/envelope_pg.go (new ClaimSignerForSubmit(signerID) (bool,error) — CAS pending→a transient ‘signing’ marker, or just guard with a signed_at IS NULL conditional update), go/internal/esign/domain/envelope.go if a status const is needed.
- [ ] Before calling ch.SubmitOTP, atomically claim the signer row so two concurrent valid submits don’t both hit the provider. Simplest: an UPDATE … WHERE id=$1 AND status='pending' claim returning rows-affected; the loser returns a clean “already being processed” conflict. Release/restore on provider error so a retry can proceed.
- [ ] go build ./... && go vet ./....
- [ ] Commit: fix(esign): atomic per-signer claim before provider submit (LOW #3)

Task 6 — codeDestination preposition (LOW #11)

Files: Modify web/src/i18n/locales/en.ts + id.ts (publicSign.codeDestination).
- [ ] Change … to {{channel}} {{phone}}… to {{channel}} at {{phone}} (EN) and the Indonesian equivalent, so the channel-fallback path reads naturally.
- [ ] cd web && npx tsc --noEmit.
- [ ] Commit: fix(esign): codeDestination wording (LOW #11)

Task 7 — OTP input: one-time-code autofill + numeric + maxLength, gate at 6 (LOW #8)

Files: Modify web/src/features/public-sign/PublicSignPage.tsx (the OTP TextInput).
- [ ] Add autoComplete="one-time-code", inputMode="numeric", pattern="[0-9]*", maxLength={6}; tighten the verify-enable gate from code.trim().length < 4 to < 6.
- [ ] cd web && npx tsc --noEmit && npx vite build.
- [ ] Commit: fix(esign): OTP input autofill + numeric + 6-digit gate (LOW #8)

Task 8 — 202 “processing” gets its own non-terminal screen (LOW #5)

Files: Modify web/src/features/public-sign/PublicSignPage.tsx, web/src/api/public-sign.ts (the submit hook already returns {completed}), web/src/i18n/locales/en.ts+id.ts (publicSign.processing.*).
- [ ] On submit success with completed === false (HTTP 202), show a DISTINCT “your signature is being finalized — you can close this page” state, NOT the terminal “Signature recorded” success. Only show the recorded/done screen on completed === true. (For sequential signers the 202 is the common case — don’t poll-block them.)
- [ ] cd web && npx tsc --noEmit && npx vite build.
- [ ] Commit: fix(esign): distinct 202 'processing' state on the public page (LOW #5)

Task 9 — Distinguish transient/network errors from a dead link (LOW #9 + #6 frontend)

Files: Modify web/src/api/public-sign.ts (usePublicSign — don’t collapse a network/5xx error into dead), web/src/features/public-sign/PublicSignPage.tsx (show a retry affordance for transient load failures; map the backend esign.otp.expired vs esign.otp.invalid/wrong-code to distinct messages from Task 4), web/src/i18n/locales/en.ts+id.ts.
- [ ] In usePublicSign, only treat 404/410 as dead; surface other failures as a transient error (let React Query retry / keep a retry button) instead of the permanent “link no longer active” screen. Map the OTP error codes to publicSign.otpExpired vs publicSign.otpWrong messages with the right next action (re-type vs Resend).
- [ ] cd web && npx tsc --noEmit && npx vite build.
- [ ] Commit: fix(esign): transient-vs-dead + expired-vs-wrong-OTP messaging (LOW #9, #6 frontend)

Task 10 — Type-mode signature scales to fit the canvas (LOW #7)

Files: Modify web/src/features/public-sign/SignaturePad.tsx (the type-mode render useEffect).
- [ ] Measure the typed text with ctx.measureText and shrink the font (or cap input length) so a long name never clips past the 760px canvas edge.
- [ ] cd web && npx tsc --noEmit && npx vite build.
- [ ] Commit: fix(esign): type-mode signature fits the canvas (LOW #7)

Task 11 — Richer roster status for the requester (LOW #10)

Files: Modify web/src/features/documents/DocumentDetailView.tsx (SignatureRequestsList) — and optionally surface counts.
- [ ] Replace the binary signed/not display with a progress summary (e.g. “2 of 3 signed” + per-signer pending/signed; for sequential, mark whose turn it is). Keep it lightweight — no backend change required beyond what useDocSignRequests already returns.
- [ ] cd web && npx tsc --noEmit && npx vite build.
- [ ] Commit: fix(esign): richer signature-request roster status (LOW #10)


GATE (after all tasks): deploy from repo root, assert all 4 modules, smoke the public page renders + an internal request still returns 201. Then STOP.


Progress