HOMELAB-2550: fix(forgejo): harden comment identity verification #78
No reviewers
Labels
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Homelab/pi-extensions!78
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "plane/HOMELAB-2550-comment-identity-hardening"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes the five non-blocking observations left by the independent reviews of PR #75 (HOMELAB-2527, merged). Base:
origin/main=0317c299. One PR, four files; no landing file, noplane/**, nodocs/forgejo.md, no merge implementation or tests (HOMELAB-2552 owns those).Red first (unmodified main)
New focused suite
forgejo/tests/comment-identity-hardening.test.ts: 13 failed | 4 passed (17) on0317c299. Log:working-files/homelab-2518/HOMELAB-2550-red.log.FORGEJO_EXPECTED_IDENTITY=ld-reviewer, tokenaaron, ordinary body,expected_identity: ""→ POST happened; whitespace-only identical;resolveExpectedIdentity("", env)→undefinedpostVerifiedCommentresolved instead of rejecting"commentOnPR" in api === true; exported comment write surface =["commentOnPR","postVerifiedComment"]verified/expectedSourceprovenancesameLogincopies already agreeThe helper's own echo fallback (
readBack?.user?.login ?? created?.user?.login) was verified red separately: with the old line restored, the new CLI test exits 0 on an author-less read-back.Fixes
resolveExpectedIdentity: a BLANK explicit value is not a declaration — it falls through toFORGEJO_EXPECTED_IDENTITY. A non-blank argument still wins (the approved contract), andexpectedSource: "param" | "env"records which applied.getIssueCommentfailure, a read-back with no author, an author mismatch and a body mismatch all throw the newUnverifiedCommentErrornaming the row; the tool reportsunverified: true+commentIdand says do not repost; one POST, never a blind retry. The helper's--postread-back no longer borrows the echo either.commentOnPRis module-private;postVerifiedComment(module-private brand) is the only exported comment write. Whole-repo grep: no other consumer.verified,unverified,commentId,expectedSource, plus the operational note that a token withoutread:usercan no longer comment at all (fail-closed by design).docs/forgejo.mdis NOT touched — PR #74 holds that file; the doc line stays tracked on HOMELAB-2550.api.tsimports./landing.js, which plainnode --experimental-strip-typescannot resolve (ERR_MODULE_NOT_FOUND, measured) — the helper's documented entry point — andlanding.tsbelongs to PR #74. Instead both copies are pinned against each other by an 8-pair semantics matrix (--authorvsGET /user), so a divergence fails the suite.Verification
comment-identity-hardening19/19 +comment-identity(HOMELAB-2527) 17/17 → 36/360317c299= 290/290 across 13 files ⇒ +19 tests, no existing test edited)tsc --noEmit: cleanPOST/PUT/PATCH/DELETE), and no test or message contains the token.Ordinary comments are preserved: a non-verdict body still posts under a readable identity with no expectation, with the env expectation, or with an explicit one.
No reviewer was dispatched by the worker; no self-verdict, no merge.
Verdict: APPROVED
model: omniroute/deepseek-flash
head:
c127f4a37bGENERAL review — HOMELAB-2550 / pi-extensions #78
Verdict: APPROVED. Model
omniroute/deepseek-flash, general reviewer, fresh context, review only.Reviewed exact head
c127f4a37bbd5e6fe41474e7fc1c4b0fb1256511, base0317c299. Head re-fetched liveimmediately before this verdict — unchanged, so no re-review was needed. No blockers, no BLOCKER OPEN lines.
forgejo/**is dangerous class, so this is the general lens only: a premium verdict on a second declaredmodel is still required before any merge.
Contract
HOMELAB-2550 owns the five non-blocking observations left by the merged PR #75 reviews. The diff is
forgejo/api.ts,forgejo/index.ts,forgejo/verdict-cli.tsand a newforgejo/tests/comment-identity-hardening.test.ts, +739/−30 — inside the ticket's declared file set. Nolanding.ts, noplane/**, nodocs/forgejo.md(correctly: open PR #74 still holds that file), and noexisting test edited.
Independently reproduced
0317c299plus this test file, throwaway copy): 14 failed / 5 passed (19).The PR body quotes 13 failed / 4 passed (17) — the log is an earlier revision of the same file; the
drift understates the red and changes nothing.
tsc --noEmit: exit 0.c127f4a37b: 14/14 jobs success (forgejo,forgejo-ci,plane,pi-swarm-suite,redact,mcp-bridge,nats-intercom,pi-otel-telemetry,pi-friction,refocus,skill-args,todo,ask-user-question,pr-title).probes, listed below. Nothing was written to the worktree or the repo.
O1 — a blank expectation is not a declaration
Blank and whitespace-only
expected_identityfall through toFORGEJO_EXPECTED_IDENTITY; a non-blankargument still wins over it, which is the approved contract and is pinned. Reproduced: env
ld-reviewer,token
aaron, ordinary body,expected_identity: ""→ refused, zero mutating requests,expectedSource: "env". Non-blank"aaron"with the same env → one POST,expectedSource: "param". Blankwith no env → ordinary comment still posts with no expectation. A blank parameter does not switch off the
verdict rule either. The source is surfaced in
detailsand in the mismatch reason, so the fall-through isvisible rather than silent.
O2 — the stored row is the only evidence
Both write paths now read, never echo. Tool: read-back 404, read-back 500, read-back 200 with no author,
read-back author
aaronagainst anld-reviewertoken, and stored-body mismatch all produceunverified: truewithcommentIdset,authorabsent,verifiednever true, exactly one POST andtext that says do not repost; no retry exists anywhere on the path. Helper: the same shapes throw with the
comment number and "do not repost", and
runClimaps them to exit 1, never exit 0.postVerifiedCommentrejects with
UnverifiedCommentErrorcarrying the row id.O3 — one guarded write surface
commentOnPRis module-private;"commentOnPR" in apiis false and the exportedcomment|post*surface isexactly
["postVerifiedComment"]. No barrel re-export, no other package importingforgejo/api, noconsumer of
commentOnPRanywhere in this repo or in the homelab checkout. A hand-rolled{ok:true,actual}proof and
undefinedare both refused with zero POSTs, so the symbol brand still holds.O4 / O5
O4 is aligned in code:
verified,unverified,commentId,expectedSource, plus the description statingthe blank rule, the read-back rule and the fail-closed
read:usernote. Its one doc line is deferred behindopen PR #74 — verified still open, so the deferral is real, and the ticket stays open to carry it. O5 is a
measured non-issue: I confirmed independently that
node --experimental-strip-typesimportingapi.tsdieswith
ERR_MODULE_NOT_FOUND … landing.js, so dedup-by-import is unavailable without touching PR #74's file;an 8-pair equivalence matrix pins the two copies instead.
Residual notes, non-blocking
The PR body's red count is one revision behind the shipped file. Two cases labelled PIN/GREEN are red on
base only because they assert the new fields — cosmetic.
expectedIdentitySource()restates the two-linerule of
resolveExpectedIdentity()in the same file and is not covered by the equivalence matrix. Theunverified result keeps
refused: false, so a consumer reading only that field could mistake it for successeven though
verified/unverified/commentIdand the text disambiguate. The helper reports an unverifiedread-back as exit 1, shared with "body is not a counted verdict", and sets no
postedid — the message namesthe comment, so it is recoverable. The refusal hint "or drop the expectation for an ordinary comment" now
reads slightly wrong, since after O1 you unset the environment variable rather than the parameter. Out of
scope but observed:
resolveLandingIdentitystill has the pre-O1 shape, where an explicitly blank landingidentity short-circuits
FORGEJO_LANDING_IDENTITYto thepi-landingdefault — the merge path belongs toHOMELAB-2552 and is unchanged here.
Why APPROVED
O1, O2 and O3 are each closed at this exact head, red-first, and I reproduced each mechanism with an
independent harness rather than accepting the worker's. I could not construct a path that comments without
GET /userfirst, that posts twice, or that reports authorship it did not read. O4 is aligned in code with alegitimately deferred doc line, and O5 is a measured non-issue with a divergence test.
Verdict: REQUEST_CHANGES
model: omniroute/k3.1-pro
head:
c127f4a37bIndependent PREMIUM review, fresh context, review only. No code edits, merge, agents, or formal review. forgejo/** is dangerous class. Poster identity and live head are checked by the deterministic helper flow before this comment.
tier: premium
I request changes for one reproduced O2 gap on the verdict-helper write path.
Blocker — H2550-K1: Verdict helper omits unverified/do-not-repost after some post-write verification failures
BLOCKER OPEN: H2550-K1
At forgejo/verdict-cli.ts:568, a read-back HTTP/network failure escapes through the generic API/catch path. My independent fake-Forgejo probe of the real runCli produced exit 1 after exactly one POST, but stderr contained only the generic 404 for the created comment: no unverified state and no do-not-repost instruction. A stored-body mismatch at lines 574-577 likewise reports the mismatch without do-not-repost. The row already exists, so this guidance gap invites a duplicate manual retry and does not meet O2 on both write paths. No-author and wrong-author branches do warn correctly. The new helper fake only models ok/noAuthor, leaving HTTP failure and body mismatch uncovered.
Close this by making every post-POST read-back/body/author verification failure identify the existing comment as unverified and explicitly forbid reposting, without retrying the POST. Add fake-API 404/500 and body-mismatch tests that assert one POST and the warning.
Everything else checked holds: O1 blank values fall through to env and nonblank explicit precedence survives; the tool uses only stored read-back evidence with unverified/commentId/do-not-repost; O3 leaves only the branded guarded comment export; O4 details/description are aligned; O5's plain-Node landing.js import failure reproduces, justifying the measured equivalence matrix. No identity bypass, unverified success, automatic repost, or further compatibility blocker found.
Independent reruns: targeted 36/36, full suite 309/309, tsc clean, diff-check clean; CI #176 is 14/14 green. Base plus the shipped test gives 14 failed/5 passed; the quoted 13/4 log is an earlier test revision. The worker worktree remains clean.
c127f4a37bb2b3d70e0eFix round 1 — resolved H2550-K1
resolved H2550-K1
Rebased onto
origin/main=fe7058b8aaba16c240d38f7421fac4e52b295200(PR #74 merged); this supersedes commitsc127f4a/0317c299referenced in the description.New head:
b2b3d70e0ea1bcf0a7555ba87243fdf4ec47dff9(one push, stable)Base:
fe7058b8aaba16c240d38f7421fac4e52b295200CI run #183 at this exact head: 14/14 jobs success.
What K1 actually was
At the old
verdict-cli.ts:568, a post-POST read-back failure escaped through the generic API/catch path, so the created comment was never named as unverified and nothing forbade a repost — the operator's natural next move (run it again) duplicates a row that already exists. Same for the stored-body mismatch at:574-577. Reproduced red on the rebased, unrevised code before touching the implementation.RED FIRST (rebased, unrevised) — log
HOMELAB-2550-red-k1.logcomment-identity-hardening: 5 failed | 18 passed (23), every case asserting exactly one POST:Forgejo API GET repos/Homelab/pi-extensions/issues/comments/9001: 404 {"message":"not found"}Forgejo API GET .../issues/comments/9001: 500GET verification failed for comment #9001: the stored body differs from the composed body…read back a body that differs…; do not repost it…(never says unverified)…was attributed to "aaron", but this token…(never says unverified)Cross-check on a throwaway archive of
origin/mainplus this test file: 18 failed | 5 passed (23); base alone 349/349.Fix
forgejo/verdict-cli.ts— every failure after the single POST answers with an id now funnels through one constructor,unverifiedWrite(commentId, detail):GET verification failed for comment #N: <detail>. The write is NOT verified (comment #N EXISTS) — do not repost it: read the PR and report it to the operator; nothing was retried.Sites: read-back HTTP/network failure (a 401/403 there is not the pre-POST auth refusal — the comment is already written, so it is an unverified write, not exit 2), stored-body mismatch, missing author, wrong author, and a POST that answers without an id. There is no retry anywhere on the path.
forgejo/api.ts— wrong-author and body-mismatchUnverifiedCommentErrormessages now also say the row is NOT verified; a POST without an id states the write cannot be verified and must not be reposted. Read-back failure/no-author already did.forgejo/tests/comment-identity-hardening.test.ts— helper fake read-back gainedmissing(404),error500(500) andotherBody; new helper cases for 404, 500 and body mismatch, plus a tool 500 case, each asserting exactly one POST and both mandated phrases; the existing tool body-mismatch and wrong-author cases now assert both phrases. 23 tests (was 19).docs/forgejo.md— the O4 line deferred behind PR #74 is now written (the description's "no docs/forgejo.md" is superseded: #74 is merged and the file is collision-free). New section Comment writes are verified, not echoed (assert identity → one POST → stored-row read-back, unverified + do-not-repost on any post-POST failure, blankexpected_identityfalls through to the env var,read:userfail-closed), plus the tool-table row and the--postparagraph.No
landing.ts, no merge identity code (HOMELAB-2552 owns it), noplane/**. O1–O5 fixes and caller compatibility untouched; the tooldetailsshape and the helper's caller surface are unchanged.GREEN at
b2b3d70e0ea1bcf0a7555ba87243fdf4ec47dff9comment-identity-hardening+comment-identity): 40/40fe7058b= 349/349, 13 files; +23 = this file's tests)tsc --noEmit: cleangit diff --check: clean —git diff fe7058b...b2b3d70 --stat:docs/forgejo.md+33/−1,forgejo/api.ts+17/−17,forgejo/verdict-cli.ts+78/−34,forgejo/tests/comment-identity-hardening.test.ts+108/−14No self-review, no verdict, no merge.
Verdict: APPROVED
model: omniroute/deepseek-flash
head:
b2b3d70e0eWhat this round had to answer
HOMELAB-2550 review #19854 (premium K3.1-pro, head
c127f4a3) raised H2550-K1: the verdicthelper's post-POST read-back HTTP/network failure and stored-body mismatch escaped through the
generic API/catch path — the created row was never named as unverified, nothing forbade a repost, so
the operator's natural fix ("run it again") duplicated a row that already existed. My prior GENERAL
verdict (#19815) at
c127f4a3was APPROVED, so this round is judged on the K1 delta plus O1–O5still holding at the new head, and on the doc line that had been deferred behind PR #74.
H2550-K1 — closed, re-measured (not accepted on the worker's word)
Commit
b2b3d70(2 commits on top of the prior head;990f737+b2b3d70:+926/−49 over 5 files, no file outside
forgejo/{api,index,verdict-cli}.ts,forgejo/tests/comment-identity-hardening.test.ts,docs/forgejo.md).unverifiedWrite()(verdict-cli.ts:443) is the single post-POST failureconstructor;
UnverifiedCommentError(api.ts:347) is the tool-side carrier. Every post-POST branchroutes through one of them, so the two pinned phrases come from one place per path.
(403/network), read-back 401/403, read-back 404, read-back 500, missing author (631),
wrong author (639), stored-body mismatch (626). All exit non-zero, all name the row where one
exists, all say NOT verified and do not repost.
UnverifiedCommentErrorand are caught in
index.ts(1016) into a structured result{refused:false, unverified:true, commentId:<row>, reason}with the text "…Nothing here may bereported as posted as """. POST-without-id throws (no row id to name) with the same
"cannot be verified — do not repost it" sentence.
verdict-cli.ts:596) and one inapi.ts(commentOnPR, 1040, module-private);request()/api()contain no retry loop; only thepre-POST
AuthRefusedstops earlier. Repo-wide grep: those are the only two comment-POST sites.My own harness (reviewer-authored,
/tmp/h2550r2probe.z8sT=git archive b2b3d70; nothing written to the worktree or repo)probe-r2.test.ts): read-back network throw / 401 / 404 / 500 /no author / wrong author / other body / POST-without-id → exactly one POST, non-zero
exit,
/NOT verified/,/do not repost/i,#\d+row naming where an id exists, token neverprinted; plus the success case still posts once and reports the id.
probe-r2-tool.test.ts): the same eight modes throughforgejo_comment_on_prwitha route table I wrote → one POST,
details.verifiednever true,unverified:true+commentIdnumeric + PR-referencing text on all seven read-back modes, loud throw with"do not repost" on POST-without-id, token never in the output.
probe-r2-o1.test.ts): 9 (explicit, env) pairs —""," "," \t "all fallthrough to
FORGEJO_EXPECTED_IDENTITY; a non-blank"aaron"/" aaron "still wins(
source: param); neither set → no expectation;resolveExpectedIdentityandexpectedIdentitySourcenever disagree;sameLogincase/whitespace rule as documented.Worker claims, independently re-run in
/home/aaron/repos/pi-extensions-2550(read-only)HOMELAB-2550-red-k1.log— 5 failedtsc --noEmitCI #183 b2b3d70e0e #78 [pass]— 14/14 jobs successO1–O5 at the new head
expected_identityis an absence, not a declaration(
resolveExpectedIdentity, api.ts:236); the source is recorded (expectedSource) and surfaced inboth refusal and success details, so a fall-through is visible rather than silent.
landing.ts'smerge identity is untouched (see N7).
?? comment.user.login/?? created.user.loginecho fallbacks are gone; a read-back that cannotbe read is a loud non-success, never an echo-backed pass.
commentOnPRis module-private (api.ts:1032);postVerifiedCommentis the onlyexported comment write;
"commentOnPR" in api === falseis pinned; nothing in the repo imports itand there is no barrel re-export.
docs/forgejo.mdis collision-free here: the tool table row, the--postparagraph (authorread-back, not-verified/do-not-repost, no retry) and the new Comment writes are verified, not
echoed section all match the code I read. Ticket may now be closed with the doc line included.
sameLoginis duplicated inverdict-cli.tsbecauseapi.tsimports./landing.js(unresolvable undernode --experimental-strip-types); theequivalence matrix in the shipped test plus my own pair check keep the copies honest. Dedup belongs
to
landing.ts's owner.Non-blocking notes (no blocker IDs)
stream (
collectState, verdict-cli.ts:648) before printingposted: comment #<id>(verdict-cli.ts:664). A network/API failure on that re-read escapes the generic catch: exit 1, the
row id never printed, no "do not repost" — the same duplicate-inviting shape K1 fixed one step
earlier, on a path where the write is verified. Outside K1's letter (not a read-back/body/author
failure) and outside this diff, so not a blocker; the cheap cure is to print the posted id before
the re-read. Owner: HOMELAB-2550 follow-up.
unverifiedshape; the doc sentence "verified: trueonly on a proven row andunverified: truecommentIdotherwise" therefore overstates — there is a third, throwing outcome. Wording only.expectedIdentitySource()re-statesresolveExpectedIdentity()'s rule in the same fileand is now user-visible in
details; the shipped suite pins the resolver, not the source function(I probed the pair myself — they agree). A one-line pin would close the prior N3 properly.
refused: false; a consumer reading only that fieldcould read it as success (
verified/unverified/commentIddisambiguate).EXIT.refused(1), shared with "not a countedverdict"; the message names the row, the exit code alone does not.
wrong after O1 (you unset the variable, not the parameter).
resolveLandingIdentitystill short-circuits an explicitlyblank landing identity to the
pi-landingdefault; the merge path is HOMELAB-2552's, unchanged.Why APPROVED
At
b2b3d70I could not construct a path on either comment-write surface where a post-POSTread-back failure, a body mismatch, a missing author or a wrong author is reported as success, is
silently swallowed, invites a blind repost, or produces a second POST. K1's two named leaks are
closed by a single funnel per path, red-first evidence is real, and my independent harness reproduces
the guarantee on both the tool and the helper for eight failure modes each. O1–O5 hold, and O4's
deferred doc line is now present and accurate. Nothing else in the diff reaches beyond the declared
scope. Merge additionally needs the premium verdict (dangerous class), which this review does not
supply.
Verdict: APPROVED
model: omniroute/k3.1-pro
head:
b2b3d70e0etier: premium
Independent fresh-context PREMIUM review of the entire five-file diff against
fe7058b8aaba16c240d38f7421fac4e52b295200. No code edits, merge, agents, or other PR. No open findings.H2550-K1 is closed. The helper funnels every post-POST read-back HTTP/network, stored-body, missing-author, and wrong-author failure through
unverifiedWrite; the tool usesUnverifiedCommentErrorand its structured unverified result. Both name the existing row, say NOT verified and do not repost, perform exactly one POST, and never retry.I independently reproduced the pre-fix red at
990f737with the shipped 23-case test: 5 failed / 18 passed. Read-only worktree reruns at the exact head: targeted 40/40, full Forgejo suite 372/372 across 14 files,tsc --noEmitclean, and diff-check clean. CI #183 is 14/14 green on this head.My reviewer-authored fake-API probe on an archived exact head passed 16/16: on both tool and helper, read-back network throw, 401, 404, 500, missing author, wrong author, body mismatch, and POST-without-id all produced NOT verified + do-not-repost with exactly one POST and no other mutation. Tool read-back failures returned
unverified: trueandcommentId, never verified success.O1 blank/whitespace expectations correctly fall through to the environment while nonblank explicit precedence remains. O2 uses stored-row evidence only. O3 leaves only the branded guarded comment export. O4 details/description align and the deferred
docs/forgejo.mdline is now present and accurate. O5 remains a measured, matrix-pinned non-issue.Blocker H2550-K1 resolved — every post-POST read-back/body/author failure now reports unverified and do-not-repost on head
b2b3d70e0e.