108 Commits
Author SHA1 Message Date
Kevin Codex 2f622ae5fa Merge pull request #182 from Gitlawb/release-please--branches--main--components--gitlawb-node
chore(main): release 0.6.0
v0.6.0
2026-07-22 09:32:24 +08:00
github-actions[bot] f9506b466e chore(main): release 0.6.0 2026-07-22 09:08:39 +08:00
Kevin Codex d5ec4695f4 test(node): add missing owner_did field to fails-closed seed update (#229)
PRs #113 and #145 raced: #145 added owner_did to ReceivedRefUpdate and
updated its own call sites, but the seed initializer in
list_repo_events_fails_closed_when_repo_lookup_errors (added by #113)
was never updated, so main fails to compile test targets (E0063).
This breaks clippy --all-targets, both test jobs, and MSRV on CI,
including the release PR #182.

owner_did stays None here on purpose: the test exercises the legacy
None-branch slug fallback.

Co-authored-by: Kevin Codex <[email protected]>
2026-07-22 09:03:07 +08:00
euxaristia 98d728c325 fix(gl): point doctor's version check at Gitlawb/node, not the frozen Gitlawb/releases repo (#198)
* fix(gl): point doctor's version check at Gitlawb/node, not the frozen Gitlawb/releases repo

check_version queried https://api.github.com/repos/gitlawb/releases/releases/latest.
Gitlawb/releases is a separate repo that stopped receiving tags after v0.3.8
(2026-03-30). Releases have shipped directly on Gitlawb/node since v0.4.0, via
release-please, and install.sh already treats Gitlawb/node as the canonical
release source. The old query target meant gl doctor's version check could
never again detect a real update.

Points the check at Gitlawb/node instead, matching install.sh's own default,
and makes the GitHub API base injectable so the fix is covered by a test
against a mocked server rather than the real GitHub API.

Closes #197

* fix(gl): check HTTP status before parsing the release response; strengthen up-to-date test

Addresses CodeRabbit review on #198:
- check_version now checks resp.status().is_success() before parsing the body,
  so a GitHub rate-limit or other API error surfaces as its actual HTTP status
  instead of a generic "could not parse latest tag".
- test_check_version_up_to_date now also asserts the detail message, so the
  test can't silently pass on an unmatched mock request (mockito's fallback for
  an unhandled request is a 501, which check_version also maps to Ok).

* test(gl): add unit test for non-2xx check_version response

* fix(gl): remove trailing blank line at EOF in doctor.rs

cargo fmt --all -- --check fails on the extra terminal blank line, and the
fmt + clippy workflow runs that check as a required gate.
2026-07-22 08:33:11 +08:00
euxaristia b6877a6598 fix(core): honor wildcard capabilities in Ucan::can, matching is_attenuated_by (#200)
* fix(core): honor wildcard capabilities in Ucan::can, matching is_attenuated_by

Ucan::can did strict equality on both resource and action, so a validly
delegated wildcard capability (with: "*", or can: "*" / "repo/admin") could
never match a concrete runtime request even though is_attenuated_by treats
those same values as wildcards during delegation-chain verification.

Not a security issue: the mismatch only produces false negatives (denying a
capability the delegation chain actually grants), never false positives.
Dormant today since no production code path calls can() yet — auth's
caller_authorized_to_push is pure did_matches(), with the UCAN-capability
check noted as future (Phase 2) work.

Makes can() mirror is_attenuated_by's semantics: the stored capability plays
the "parent" role, so with == "*" matches any resource and can == "*" or
"repo/admin" matches any action.

Closes #199

* test: add can_honors_action_wildcard unit test for Ucan::can
2026-07-22 08:32:29 +08:00
beardthelionandt b07d41ea97 fix: strip Cf bidi/format controls in terminal-bound error sanitizers (#183) (#188)
* feat(core): add shared is_bidi_format predicate for terminal sanitizers (#183)

Terminal-bound error sanitizers must strip Cf bidi/format controls as well as Cc control bytes (INV-6), or a right-to-left override in attacker-influenceable bytes can visually reorder the displayed line (Trojan Source, CWE-451). gl already did this with a local is_bidi_format; two sibling sanitizers shipped with only the Cc half (#183). Centralize the reordering-subset predicate here so all consumers reuse one audited definition instead of re-deriving the set. Deliberately excludes legitimate Cf chars (ZWJ, soft hyphen).

* refactor(gl): consume shared is_bidi_format from gitlawb-core (#183)

Replace gl's local is_bidi_format copy with the centralized gitlawb-core predicate so exactly one definition exists in the workspace and the three terminal sanitizers cannot drift apart. Behavior-preserving; the existing strip test is unchanged and a must-not-over-strip guard (ASCII + Arabic U+0627 + ZWJ U+200D survive) pins the reordering-only scope.

* fix(git-remote): strip Cf bidi controls from terminal-bound error bodies (#183)

safe_error_body_excerpt filtered only Cc control bytes, so a Cf bidi override in a hostile or MITM'd node's HTTP error body reached the operator's terminal and could visually reorder the line (Trojan Source, CWE-451). Drop the reordering Cf subset via the shared gitlawb-core::sanitize::is_bidi_format, without setting pending_space so stripped chars leave no phantom gap. Covered per bidi class plus a transport-path proof through handle_connect, RED observed before the fix.

* fix(icaptcha): strip Cf bidi controls from terminal-bound error excerpts (#183)

sanitize_excerpt filtered only Cc, so a Cf bidi override in a node-advertised iCaptcha service's reason/body reached the terminal on the bail! path (Trojan Source, CWE-451). Drop the reordering Cf subset via the shared gitlawb-core predicate, without pending_space. The existing sanitize_strips_control_chars_and_escapes test asserted only is_control() and so was vacuous for this class (why the gap shipped); a dedicated per-class bidi test now covers it, RED observed before the fix.

* style: apply rustfmt to #183 sanitizer additions

* test(#183): pin a leading-position bidi char in the strip tests

The per-class strip tests placed bidi chars only mid-string. A bidi char at index 0 exercises the empty-excerpt/out path (where the pending_space guard behaves differently), confirming a leading override still yields no phantom leading space. Traced correct; now asserted.

* test(review): pin that bidi chars don't consume the length cap (#183)

Correctness, adversarial, and testing reviewers all flagged that no test locks in the drop-before-count ordering: a dropped bidi char must not consume MAX_ERR_CHARS budget. Behavior verified correct by execution during review; this test guards it against regression (interleaved bidi across the cap still yields exactly MAX_ERR_CHARS visible chars, no phantom spaces).

---------

Co-authored-by: t <t@t>
2026-07-22 08:29:49 +08:00
beardthelionandt c7427e943d test(icaptcha): assert obtain_proof consumes the mocked service (#184)
* test(icaptcha): assert obtain_proof consumes the mocked service

Add an integration test that runs obtain_proof against a mockito server and
asserts both /v1/challenge and /v1/answer were consumed, so the flow is proven
to call the service rather than short-circuit or make a live call. Adds mockito
as a dev-dependency.

* test(icaptcha): block non-mock egress so the no-live-call guard is real (#184)

The mock-consumed assertions only prove the two mock endpoints were hit; they
cannot see a request that also leaks the DID, answer, or API key to DEFAULT_URL
or any other origin, so the test's stated "no live call" guarantee was untested.

Blackhole every non-loopback destination for the flow: NO_PROXY lets the
loopback mock through while ALL_PROXY routes any other host to a closed port, so
the client contacting anything but the injected mock fails the request. Verified
load-bearing: pointing cfg.url at DEFAULT_URL makes obtain_proof fail with a
proxy tunnel error (RED), and the loopback-mock path still passes (GREEN).

* test(icaptcha): blackhole scheme-specific proxy vars so the no-live-call guard covers https

The mock-consumed guard blackholed non-loopback egress with ALL_PROXY only, but
reqwest honors HTTPS_PROXY/https_proxy (and the http equivalents) OVER ALL_PROXY
for https URLs. On a runner whose environment has a working HTTPS_PROXY, a
regression could still leak the DID/answer/API key to the https DEFAULT_URL while
both loopback mock calls are consumed and the test stayed green. Override the
scheme-specific proxy vars (both cases) to the closed port too; NO_PROXY keeps the
loopback mock direct. Single #[test] per process, so the override needs no restore.

Verified: a throwaway repro showed reqwest routing an https request to HTTPS_PROXY
past the ALL_PROXY blackhole (the gap); with the fix the override sends that egress
to the closed port even when a working HTTPS_PROXY is pre-set; the guard still goes
RED when cfg.url points off-loopback.

---------

Co-authored-by: t <t@t>
2026-07-22 08:25:53 +08:00
GravireiandGravirei 69a62e51c2 fix(node): carry full owner DID on ref-update wire event (#144) (#145)
* fix(node): carry full owner DID on ref-update wire event (#144)

* test(node): add ref-update owner_did round-trip, DB, and API tests (#144)

* fix(node): move owner_did migration to v10, add upgrade-path test

* fix(node): add owner_did to GraphQL, broadcast, and REST feed surfaces (#144)

* fix(node): address review feedback — drop ipfs regression, fix doc, improve migration test, use local owner_did

* fix(node): fix migration v10 test to actually start from v9 schema; complete owner_did API assertion

* fix(node): show local owner on global feed, assert nullable in migration test, add owner_did echo assertions

* fix(node): prefer stored wire owner_did over lossy slug-local resolution; mirror projection in GraphQL

* fix: address review findings — restore iCaptcha PoW/origin matching, fix feed owner_did projections

* fix: match icaptcha-client Cargo.toml ordering with upstream/main

sha2 was at line 16 in our branch but at line 18 in upstream/main.
The 3-way merge in CI's pull/145/merge checkout kept both lines,
causing a 'duplicate key' error. Moving sha2 after tracing matches
upstream/main exactly, so the merge commit will have a single entry.

* fix(node): bind wire owner_did to local repo before surfacing as trusted

The peer-supplied owner_did (HTTP notify + gossipsub receive) and the
repo slug arrive over untrusted paths, yet REST and GraphQL projected the
wire value as trusted feed ownership.

- P1: add Db::resolve_ref_update_owner_did so a wire owner_did is echoed
  only when it matches the canonical owner of the actual local repo at that
  slug; otherwise it is dropped (returns null). The canonical DID is
  returned, never the raw wire bytes.
- P3: legacy owner_did = None rows are now attributed via an exact,
  normalized, unique local match (get_repo), not the prefix-tolerant drop
  gate, so a cross-method slug collision cannot synthesize the wrong owner.
- Unify REST (global + per-repo gossip) and GraphQL on the shared resolver
  so the two surfaces cannot drift. Add tests for forged-wire drop and
  exact-legacy attribution.

* style(node): apply cargo fmt to owner_did resolver

* fix: batch owner_did resolution, propagate errors, preserve full DID for mirrors

P2 batch: replace per-row join_all with resolve_ref_update_owner_dids
that issues at most one get_repo query per distinct local repo in the
page rather than one per event row. Applies to both REST and GraphQL
surfaces.

P2 error: REST handler now propagates DB errors (rather than silently
coercing them to null) by calling the batch method with ?.

P2 mirror: when the validated wire DID carries a full method prefix
(e.g. did:key:z...) and the matching repo is a mirror storing only
the bare key, return the full wire DID instead of the bare key to
preserve the DID-method contract.

P1/P3 already addressed in fb553e6.

* fix: rebase onto current main and use single batch SQL for owner DID resolution

Rebased from stale 3ec8cbf base onto current upstream/main (ad7c2b2),
dropping the already-merged iCaptcha work from the diff. The PR diff
is now only the 11 owner-DID files.

The batch owner-DID resolver now issues a single set-based SQL query
with one OR condition per distinct slug, replacing the loop of per-key
get_repo calls. A 200-row page of 200 distinct remote slugs now costs
1 SQL round-trip instead of 200.

P1/P3 already addressed in c64c082. P2 mirror, error propagation,
and the earlier batch refactor preserved in the rebased commits.

---------

Co-authored-by: Gravirei <[email protected]>
2026-07-22 08:24:48 +08:00
beardthelionandt ee8d7c03b2 fix(node): gate /hooks and sibling read surfaces leaking private repo metadata (#94) (#113)
* fix(node): owner-gate list_webhooks to stop leaking webhook URLs (#94)

list_webhooks sat on the optional_signature read group with no auth or
ownership check, so any unauthenticated caller who knew a repo's owner/name
could enumerate its webhooks (target URL, created_by_did, events; only the
secret was redacted).

Gate it read-visibility-then-owner, mirroring create_webhook/delete_webhook:
headerless -> 401, non-reader of a private repo -> 404 (existence hidden,
uniform with the sibling read surfaces), non-owner of a public repo -> 403.
Add a list_webhooks row to the authz_guard drift table so the gate can't
silently regress.

* fix(node): read-visibility-gate list_replicas for private repos (#94)

list_replicas sat on optional_signature with no visibility check, so a
private repo's replica URLs were enumerable by anyone who knew owner/name.
Replica lists are a documented public mirror-discovery surface, so gate on
read visibility (authorize_repo_read) rather than owner-only: a public repo
stays anonymously listable, a private repo returns 404 to anyone who cannot
read it.

register_replica registers non-owner DIDs, and a replica operator is not a
visibility reader, so a non-owner replica operator of a private repo gets
404 — the intended contract, pinned by the new test. Add the authz_guard
row.

* fix(node): read-visibility-gate list_protected_branches (#94)

list_protected_branches sat on optional_signature with no visibility check,
leaking a private repo's branch names to any unauthenticated caller. Gate on
read visibility (authorize_repo_read at the root, INV-2 root-listing
exception): public repos stay anonymously listable, private repos 404 to a
non-reader. Add the authz_guard row.

* fix(node): gate list_repo_events for local private repos, keep gossip-only (#94)

list_repo_events served a private repo's ref certificates and gossip ref
metadata (ref names, SHAs) to any unauthenticated caller. The handler also
tolerates a repo it knows only via gossip (no local row) and legitimately
serves its events, so a blanket get_repo->authorize_repo_read swap would have
404'd that federation path.

Gate only the locally-hosted branch: when get_repo returns a record, run
visibility_check on it at the root and 404 on deny before fetching either the
cert or gossip events. A gossip-only repo (no local row) keeps its existing
public federation-feed behavior; the global /api/v1/events/ref-updates feed
remains a separately tracked deferral. Add the authz_guard row (visibility_check
marker, since the gate is direct to avoid a second get_repo).

* fix(gl): sign the webhook list request now that GET /hooks is owner-gated (#94)

`gl webhook list` built its NodeClient with no keypair, so the GET went out
anonymously. With the node now owner-gating GET /hooks, that request returns
401. Thread a --dir through the List subcommand and load the keypair, matching
`gl webhook create`/`delete`, so the listing is signed as the owner.

(Replica and protected-branch listings stay anonymous for public repos under
read-visibility gating, so their gl clients need no change.)

* fix(review): sign the webhook list GET (get_signed), not plain get (#94)

Code review caught that NodeClient::get() never attaches the RFC 9421
signature even when a keypair is loaded — only get_signed/post/put/delete
sign. The earlier gl webhook-list fix loaded the keypair but still called
get(), so `gl webhook list` (and the MCP webhook_list tool) 401'd against
the now-owner-gated endpoint. Switch both to get_signed.

Harden the two cmd_list tests to require a Signature/Signature-Input header on
the mock (verified: they fail against plain get(), pass with get_signed) — the
missing assertion that let the bug through. Add the untested owner-of-private-
repo webhooks path (200 + redaction). Fix the stale module doc and the
authz_guard bucket comment for list_webhooks.

* fix(gl): sign read-visibility GETs so private-repo owners can read their own repos (#94)

The #94 server change read-visibility-gates GET /replicas and
/branches/protected: public repos stay anonymous, private repos require the
owner/reader to authenticate. Four gl read commands sent unsigned GETs and so
broke (404) or silently degraded (empty output) for a private repo's owner:
gl protect list, gl repo owner (silent empty protected list), gl repo info
(silent missing replica count), gl repo replicas (and it had no --dir flag).

Add NodeClient::get_maybe_signed — signs when an identity is present, stays
anonymous otherwise (mirrors the conditional signing of post/put/delete) —
and route the four call sites through it. Add --dir to `gl repo replicas`.
Harden the protect-list tests to assert the request is signed (verified: they
fail on plain get()). Public-repo anonymous reads are unchanged.

* fix(review): sign the primary repo fetch in gl repo info/owner too (#94)

Round-2 review caught that cmd_info and cmd_owner loaded a keypair and signed
their replica/protected-branch sub-fetch but left the PRIMARY GET
/api/v1/repos/{owner}/{name} on plain get(). get_repo is read-visibility-gated,
so for a private repo that primary fetch 404'd first and the command bailed
before reaching the signed sub-fetch — the half-fix never actually let an owner
inspect their own private repo. Route both primary fetches through
get_maybe_signed.

Add the missing signing guards: harden test_cmd_owner_is_owner to require a
signature on both mocks, and add tests for cmd_replicas and cmd_info (both were
untested) asserting the request is signed when an identity is present.

Out of scope (pre-existing, gated before #94, untouched here): gl repo commits,
gl pr list/view/diff, and the MCP repo_get/repo_commits/pr_* tools call other
already-gated read endpoints unsigned and are broken for private repos
independently of this change.

* test(review): execute the previously-reasoned-only paths (#94)

Close the vetting gaps from the review:
- node: a listed reader (non-owner) passes the read gate but is still refused
  the webhook list (403), and headerless on an existing-private vs an absent
  repo both 401 — proving the 401 fires before any lookup, so it is not an
  existence oracle.
- node: the read-visibility surfaces (replicas, protected branches) admit a
  listed reader who is not the owner (the allow-list branch of visibility_check),
  while a non-reader stranger still 404s.
- gl: get_maybe_signed's anonymous fallback (no identity -> unsigned GET) is now
  exercised, RED-proven by flipping it to get_signed (which signs and fails the
  no-signature-header assertion).

* test(review): execute the MCP dispatch and gl->node signature seams (#94)

Close the last two reasoned-only paths by execution:

- MCP webhook_list: a call_tool("webhook_list", ...) dispatch test against a
  mock node asserts the /hooks request carries a signature (get_signed).
  RED-proven: flipping the arm back to get() fails the header assertion.

- gl->node end-to-end: a real RFC-9421 signature produced exactly as the gl
  client's get_signed does (gitlawb_core::http_sig::sign_request over GET +
  empty body) is run through the node's actual optional_signature middleware,
  which verifies it and authorizes the owner (200 + webhook body). RED-proven:
  signing over POST while sending GET makes the node reject it. This stitches
  the gl signing side and the node verifying side in one test rather than
  mocking one end.

* fix(review): fail closed when the repo lookup errors in list_repo_events (#94)

`get_repo(...).await.ok().flatten()` collapsed a lookup Err into None, so a
transient DB failure skipped the read-visibility gate and the handler served a
private repo's gossip ref-update events. Propagate with `?` instead: an error
now maps to AppError::Internal (500) and fails closed, while a genuine Ok(None)
(repo not hosted locally) stays the intentional ungated pass-through.

Add a regression test that forces a get_repo error (drop the column its SELECT
reads) and asserts 500 with no secret in the body. The injection is
self-verifying: it asserts the lookup succeeds pre-drop and errors post-drop, so
the test can't pass vacuously if get_repo's projection changes later.

* fix(node,gl): close residual gaps in repo-read gating (#113)

Address jatmn's review feedback on the read-surface gate:

- gl webhook: resolve owner from the signing identity, not the node DID.
  cmd_create/list/delete all routed through the local resolve_owner helper,
  which only ever returned the node root DID, so for a repo owned by the
  local identity the owner-gated /hooks route returned 404/403. Drop that
  helper and reuse repo::resolve_owner_repo_pair (accepts owner/repo, else
  derives from --dir/keypair). Fixed across all three subcommands so
  create-writes and list/delete-reads stay on the same owner.

- gl webhook list: check the HTTP status before reading `webhooks`. A
  401/403/404 body has no webhooks field and previously printed
  "No webhooks" and exited 0, hiding the rejection; now bails with the
  server message like cmd_create.

- visibility::is_owner: delegate to api::did_matches so the read gate and
  require_repo_owner share one normalization. Fixes the under-match (bare
  owner stored, full did:key caller, as mirror rows) that denied the owner
  with the opaque 404, and the cross-method over-match.

- authz_guard: pin both halves of list_webhooks (authorize_repo_read and
  require_repo_owner) so removing the existence-hiding gate fails the drift
  test.

cargo fmt/clippy clean; 644 workspace tests pass.

* test(node)+fix(gl): close events reader-coverage gap, align label-list signing

Self-review follow-ups on the read-gate work:

- list_repo_events: add list_repo_events_admits_listed_reader, pinning the
  allow-list branch of the events gate (a listed non-owner reader gets 200
  with the cert; a non-reader stranger 404s with no leak). Mirrors the
  read_visibility_admits_listed_reader coverage the sibling replica and
  protected-branch surfaces already had; the events handler was the only
  gated read surface missing it.

- gl repo: cmd_label_list now loads an identity and uses get_maybe_signed
  instead of a bare anonymous get(), matching cmd_replicas/cmd_info. Public
  repos stay anonymously listable; a private-repo owner can read their own
  labels once /labels is read-visibility-gated, with no silent client drift.

fmt/clippy clean; 645 workspace tests pass.

* fix(review): pin the label-list signing and the events 404 sha-no-leak

Final-review hardening of the prior two follow-ups:

- gl repo label-list tests: require the signature/signature-input headers on
  the signed-branch mocks, and add an anonymous (no-identity) test asserting
  the header is absent. Without the header assertion the tests passed even
  with a bare get(), so the signing change was not actually pinned
  (RED-verified: reverting to get() now fails both signed-branch tests).

- list_repo_events_admits_listed_reader: also assert the cert sha (not just
  the ref name) is absent from the stranger's 404 body, matching the
  list_repo_events_is_read_visibility_gated leak-check convention.

fmt/clippy clean; 646 workspace tests pass.

* fix(gl): error on 2xx webhook-list body missing the webhooks array (#113)

A 2xx /hooks response without a `webhooks` array could hide a node/API
contract regression. cmd_list now errors with context instead of mapping
it to "No webhooks". An empty-but-present array stays a valid no-webhooks
result. Adds a test for the missing-array case.

* fix(gl,node): scope MCP webhook_list owner to keypair, gate list_labels on read visibility (#113)

- MCP webhook_list: default owner to the signing keypair's short DID (explicit
  owner arg still overrides) instead of the node root DID, so the signed,
  owner-gated /hooks request resolves to the caller's own repo. Mirrors the
  gl webhook list fix. resolve_owner and webhook_create/delete left untouched.
- list_labels: gate on authorize_repo_read so a private repo's label names 404
  for non-readers (public repos stay anonymously listable), matching the sibling
  read surfaces and making the existing client comment true. Added the
  authz_guard marker and a read-visibility integration test.
- gl label list: check HTTP status before reading the body so the now-gated
  404 surfaces as an error instead of printing "No labels" and exiting 0,
  matching the sibling list clients (replicas, protected branches).

* fix(gl): surface denied repo info reads instead of fabricating a repo (#113)

cmd_info parsed the GET /api/v1/repos/{owner}/{name} body without checking
the HTTP status, so a private-repo JSON 404 was rendered as a fake repo
summary (owner / ? / public / main) and exited 0. Capture the status and
bail on non-2xx, matching the sibling list commands.

Also assert the anonymous 404 label-list body does not leak the label name,
closing the last unread-body branch in the read-visibility gate test.

* test(node): drop #113-gated handlers from KNOWN_UNGATED; sync lockfile to 0.4.0

The repo-scoped egress guard's allowlist (added in #122, merged after this
branch was opened) still listed the read surfaces this branch gates. Now that
list_labels, list_repo_events, list_webhooks, list_replicas, and
list_protected_branches are read-visibility gated, the staleness assert
requires removing them from the allowlist. The #120 surfaces (certs, issues,
bounties, stars) stay listed; git_info_refs stays until #119 lands. Lockfile
versions follow the 0.4.0 release the rebase landed on.

* fix(gl): fail the MCP webhook_list tool on non-2xx node responses

The MCP webhook_list path deserialized the response body and returned it as a
successful tool result without checking the HTTP status, so a 401/403/404 JSON
error (missing identity, wrong owner, private or deleted repo) was reported as
success with the error body as the result. Check status().is_success() before
deserializing and surface the node's message as a tool error, matching the
`gl webhook list` path. Covered by a non-2xx test (RED without the check).

* fix(gl): read the protected_branches key in repo owner

gl repo owner parsed the protected-branch response as a bare array or a
"branches" key, but the node returns {"protected_branches":[...],"count":N},
so it always printed "Protected: (none)" even when branches were protected.
Extract the parse into parse_protected_branches matching the gl protect list
shape, and update the owner tests to mock the real schema instead of bare
arrays (which is why the bug went uncaught).

* test(gl): cover the deny/negative arms reconciled by the merge

The merge changed three gl branches that no existing test executed (they
were only reasoned): resolve_owner_did with no local identity, cmd_info on
an authenticated-but-denied 403, and get_maybe_signed with no keypair. Add
a test per branch asserting the must-not case (error/unsigned, never a
fabricated repo card). Verified load-bearing by mutation: disabling the
cmd_info status gate turns the 403 test red.

---------

Co-authored-by: t <t@t>
2026-07-22 08:24:09 +08:00
GravireiandGravirei ad7c2b2b83 test(gl): add tests for iCaptcha transparent retry flow (#167) (#168)
* fix(node): gate GET /ipfs/{cid} on reachable allowed-set, not deny-set (#126)

The IPFS visibility gate used withheld_blob_oids (a deny-set enumerating
only reachable blobs), so a dangling/unreachable blob was absent from the
set and served in cleartext to anonymous callers. Flip to an allowed-set
(allowed_blob_set_for_caller) that enumerates reachable blobs the caller
may read: a dangling blob has no path, is never in the set, and 404s.

* perf(ipfs): check object existence before allowed-blob walk

Move store::read_object before the allowed_blob_set_for_caller
spawn_blocking call so random-CID spray against repos with
path-scoped rules cannot trigger full-history git walks on
repos that don't carry the object.

* refactor(ipfs): improve formatting and readability in get_by_cid function

✓ P3 blocker fixed: cargo fmt applied — the format gate will pass.

  ✓ P3 cleanup resolved: withheld_blob_oids is still used by replication code in repos.rs, so it stays.

  • P2 follow-up: Tree/commit disclosure tracked in #135 — out of scope here.

* refactor(ipfs): streamline object retrieval by separating type and content reading

* docs(ipfs): update get_by_cid comment to reflect split object retrieval

* Run cargo fmt on store.rs

* test(gl): add tests for iCaptcha transparent retry flow (#167)

* fix(clippy): remove redundant borrow in format! arg

* fix(review): use runtime GITLAWB_ICAPTCHA_INSECURE flag instead of cfg(test), add mock assertions

* fix(review): set mock expectations at creation time, tighten insecure scheme check

* fix(test): serialize icaptcha integration tests with global Mutex to prevent env-var race

* fix(icaptcha): compare origins (scheme+host+port) in resolve_solver_url; tighten proof assertion in test

* fix(test): save/restore inherited iCaptcha env vars instead of unconditional remove; share lock in icaptcha-client tests

* fix(test): constrain negative send_once mocks with Matcher::Missing so they actually protect the contract

---------

Co-authored-by: Gravirei <[email protected]>
2026-07-10 22:22:30 +08:00
Kevin Codex c98b503f90 feat(icaptcha-client): solve the iCaptcha proof-of-work on answer (#181)
iCaptcha now requires a proof-of-work with each answer (enforce mode) so that
minting a proof costs CPU — the lever that stops a distributed, multi-IP
proof-farming flood that per-IP rate limits cannot. Teach the sanctioned
client to solve it: parse the `pow` field from the challenge, grind a nonce
whose sha256(challenge:nonce) has >= difficulty leading zero bits, and send it
as `powNonce` on /v1/answer. Bounded search so a hostile difficulty can't
hang the client. Mirrors icaptcha/src/pow.ts. Without this, agents on the
updated client cannot obtain a proof once enforce is on.

New pow module + unit tests; verified end-to-end against the live enforce-mode
service (obtained a level-3 proof incl. PoW).
2026-07-10 22:02:58 +08:00
github-actions[bot] 3ec8cbf1e1 chore(main): release 0.5.1 (#158)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
v0.5.1
2026-07-10 19:46:16 +08:00
GravireiandGravirei 6b5e5bc7ae fix(node): bound list_ref_certificates with LIMIT and add upsert to prevent unbounded growth (#147) (#149)
* fix(node): bound list_ref_certificates with LIMIT and add upsert to prevent unbounded growth (#147)

* test: add INV-7 v10 upgrade-path dedup test

* fix INV-7 test: correct id-DESC tiebreaker ids and fix fmt

* fix: insert_ref_certificate returns persisted id via RETURNING; issue_ref_certificate uses it

* fix: guard cert upsert on issued_at so older certs cannot regress newer ones

* fix: .max(0) in events limit parsing, re-add lost cert tests after rebase

* address reviewer findings: return full persisted row, add monotonic clock comment, note test drift risk

* fix: add ?prefix= server-side cert lookup for CLI short-ID resolution

---------

Co-authored-by: Gravirei <[email protected]>
2026-07-10 19:44:43 +08:00
dfcaa22b23 fix(node): rate-limit repo/agent creation per client IP to stop DID-farm spam floods (#180)
* fix(node): add per-IP flood brake to repo/agent creation endpoints

The recurring spam-repo floods are not bypassing the iCaptcha gate — they
pass it. The enforced level (3) draws only machine-solvable deterministic
challenges (arithmetic/algebra/sequence), and the /v1/challenge API lets the
caller pin the easy type, so a bot mints a fresh throwaway did:key, solves a
real proof bound to that DID, and creates a repo. The per-DID creation
limiter never trips because each DID makes a single create call.

Add a per-client-IP rate limiter to the creation route group (repos,
register, fork, issues, pulls), reusing the same IpRateLimiter the push path
already uses against the identical DID-farm bypass. It wraps the auth layer
(outermost) so flood traffic is rejected before signature verification.
Default 120/hour/IP, GITLAWB_CREATE_RATE_LIMIT overrides, 0 disables.

* docs(env): document GITLAWB_CREATE_RATE_LIMIT in .env.example

* fix: apply CodeRabbit auto-fixes

Fixed 1 file(s) based on 1 unresolved review comment.

Co-authored-by: CodeRabbit <[email protected]>

* test(node): unit-test the per-IP creation brake runs before auth

The route-level coverage (repo_creation_is_rate_limited_by_ip) is
#[sqlx::test] and needs Postgres, so it is skipped in the plain unit-test
pass. Add a DB-free middleware test that layers the per-IP creation limiter
over an always-401 auth stand-in and asserts an exhausted IP is rejected
with 429 before auth runs — the ordering the DID-farm flood defense relies
on.

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: CodeRabbit <[email protected]>
2026-07-10 19:44:04 +08:00
2109d08e2c Fix/db startup resilience (#170)
* Keep node alive during database outages

* Fix startup-resilience gaps: reachable 503s, bounded retries, seamless handoff

- Route sqlx errors buried in anyhow chains back to AppError::Db and widen
  the unavailable class (Io/Tls/pool), so DB outages return 503 instead of
  500 — the previous 503 arm was unreachable dead code
- Bound each connect+migrate attempt (GITLAWB_DB_CONNECT_TIMEOUT_SECS) so a
  held migration advisory lock cannot wedge the startup retry loop forever
- Share one bound socket between the degraded and full servers and stop
  awaiting the degraded drain: no closed-port window during the handoff and
  no slow client can block the full server from starting
- Probe the database from /ready (2s bound) and gate Fly routing/deploys on
  it, so mid-life outages drain traffic and a bad DATABASE_URL fails the
  deploy visibly instead of going green; classify auth/catalog errors as
  likely-permanent (error-level log, straight to max backoff)
- Start the metrics listener before the DB connects so the degraded window
  is observable; degraded /health now returns 503 so peer pings and uptime
  monitors stop counting a DB-less node as alive
- Move the five GITLAWB_DB_* knobs onto clap Config (validated, in --help),
  drop the redundant outer connect timeout, default the pool to 20 conns,
  fix the backoff exponent cap that defeated GITLAWB_DB_RETRY_MAX_SECS, and
  strip at most one .git suffix in smart-HTTP repo names

* fix: apply CodeRabbit auto-fixes

Fixed 1 file(s) based on 1 unresolved review comment.

Co-authored-by: CodeRabbit <[email protected]>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: CodeRabbit <[email protected]>
2026-07-10 09:44:02 +08:00
GravireiandGravirei c5e5099def refactor(visibility): route is_owner through the shared did_matches matcher (#153) (#156)
* fix(node): gate GET /ipfs/{cid} on reachable allowed-set, not deny-set (#126)

The IPFS visibility gate used withheld_blob_oids (a deny-set enumerating
only reachable blobs), so a dangling/unreachable blob was absent from the
set and served in cleartext to anonymous callers. Flip to an allowed-set
(allowed_blob_set_for_caller) that enumerates reachable blobs the caller
may read: a dangling blob has no path, is never in the set, and 404s.

* perf(ipfs): check object existence before allowed-blob walk

Move store::read_object before the allowed_blob_set_for_caller
spawn_blocking call so random-CID spray against repos with
path-scoped rules cannot trigger full-history git walks on
repos that don't carry the object.

* refactor(ipfs): improve formatting and readability in get_by_cid function

✓ P3 blocker fixed: cargo fmt applied — the format gate will pass.

  ✓ P3 cleanup resolved: withheld_blob_oids is still used by replication code in repos.rs, so it stays.

  • P2 follow-up: Tree/commit disclosure tracked in #135 — out of scope here.

* refactor(ipfs): streamline object retrieval by separating type and content reading

* docs(ipfs): update get_by_cid comment to reflect split object retrieval

* Run cargo fmt on store.rs

* refactor(visibility): route is_owner through the shared did_matches matcher (#153)

* style(profiles): remove redundant borrow in format arg

---------

Co-authored-by: Gravirei <[email protected]>
2026-07-10 08:55:49 +08:00
beardthelionandt cd67718f49 fix(node,git): bound a hung served git with a total-duration timeout (#62) (#165)
* feat(node,git): bound a hung served git with a total-duration timeout (#62)

Part 1 of #62. A served git upload-pack/receive-pack that neither finishes
nor disconnects parks forever, pinning a git PID (and, for receive-pack,
holding the write lock); on a public repo an anonymous caller can trigger
it. No tower TimeoutLayer exists and axum imposes no default.

Wrap the child interaction in tokio::time::timeout, bounded by a new
GITLAWB_GIT_SERVICE_TIMEOUT_SECS knob (default 600, must be positive).
Drain stdout/stderr concurrently with the stdin write so a large body
can't deadlock. On expiry run_git_service returns a typed GitServiceTimeout
and reaps the WHOLE process group before returning (SIGTERM so git clears
its .git/*.lock, wait for the leader and its grandchildren like index-pack
to exit, escalate to SIGKILL past a grace, hard-capped), so a caller
releasing the receive-pack write lock can't race a still-live git on the
same repo.

The handlers map GitServiceTimeout to a distinct 504 (not the generic 500
git error, and clear of the read-gate 404 / auth 401 the client keys
retries on) via a pure, unit-testable classifier rather than matching the
anyhow string. Surface git's own non-zero exit (its stderr) before a
stdin-write EPIPE so a malformed body is classified 400, not 500.

Out of scope, deferred in #62: the info/refs advertisement and the
withheld-blob path (spawn_blocking, which tokio timeout cannot cancel);
both remain unbounded (noted in the config knob's docs).

Tests: the timeout fires and tears the group down; it reaps the whole
group before returning (RED if the reap is leader-only); the wrap is
load-bearing (RED via an outer bound, not a hang); the error must be the
typed timeout; the 504 mapping and classifier are unit-tested; config
rejects 0. Full suite green.

* fix(review): harden #62 timeout tests and drop a dead reap flag

- test(node): cover the `protocol error` arm of git_service_app_error
  independently (the input has no "bad line length" substring, so it
  isolates the second classifier arm and goes RED if that arm is removed)
- test(node): make the reap-before-return test's grandchild IGNORE SIGTERM
  (`trap "" TERM`) and outlive the ~4s reap cap, so the SIGKILL escalation
  is load-bearing. Previously the grandchild self-exited under the cap, so
  neutering the SIGKILL still passed; the escalation was exercised but not
  required. Verified: RED with SIGKILL disabled, GREEN with it.
- test(node): add a receive-pack timeout test. Every prior timeout test used
  an upload-pack fake; this proves the push path (which also holds the repo
  write lock) is bounded too. RED-verified with the internal timeout removed.
- test(node): poll for the pidfile in the reap-before-return timeout test
  so a loaded runner can't false-panic before the fake git writes it
- remove the redundant `sigkilled` guard in reap_group_on_timeout; step
  == 200 fires exactly once in the 0..400 loop, so no re-entry guard is
  needed and the SIGKILL still fires exactly once

* fix(node): log malformed receive-pack as warn, not error (#165)

git_receive_pack's error classifier routed every non-Timeout error,
including client-caused BadRequest (400), through tracing::error!. Mirror
the git_upload_pack arm so a malformed push logs at warn level instead of
as a server error.

* test(node,git): make smart_http timeout tests pass under the parallel runner (#165)

Addresses jatmn's review of 41eca0b.

[P2] The #62 timeout tests failed under `cargo test --workspace`:
- run_git_service_tears_down_group_when_future_dropped re-polled a completed
  future ("async fn resumed after completion"): the advance-until-pids loop
  ignored the timeout's Ok(_) (future-finished) case and polled the resolved
  future again. It now breaks on early completion.
- Under fork-storm load a freshly-written fake `git` transiently fails to exec
  (ETXTBSY) or is timed out before recording its pids. Added a
  fake_git_run_with_pids retry helper (used by the four pid-reading tests) plus
  a matching inline retry on the drop test, with growing backoff for the
  correlated bursts and a 12-attempt cap that still fails loudly on a genuine
  never-spawns regression. Reverted two tests from a speculative 2000ms bound
  back to 300ms/1000ms now that the retry covers the timeout-vs-pidfile race.

[P3] Documented GITLAWB_GIT_SERVICE_TIMEOUT_SECS in .env.example and the README
config table (default 600; bounds upload-pack/receive-pack, not info/refs or the
withheld-blob path).

The tests stay load-bearing: breaking process_group(0), the post-reap disarm,
the timeout-arm reap, or the internal timeout each still turns the corresponding
test red (mutation-verified).

---------

Co-authored-by: t <t@t>
2026-07-10 08:54:59 +08:00
GravireiandGravirei 6bafaa6dc5 fix(bounties): add tests for claim_bounty repo-read gate (#160) (#169)
* fix(node): gate GET /ipfs/{cid} on reachable allowed-set, not deny-set (#126)

The IPFS visibility gate used withheld_blob_oids (a deny-set enumerating
only reachable blobs), so a dangling/unreachable blob was absent from the
set and served in cleartext to anonymous callers. Flip to an allowed-set
(allowed_blob_set_for_caller) that enumerates reachable blobs the caller
may read: a dangling blob has no path, is never in the set, and 404s.

* perf(ipfs): check object existence before allowed-blob walk

Move store::read_object before the allowed_blob_set_for_caller
spawn_blocking call so random-CID spray against repos with
path-scoped rules cannot trigger full-history git walks on
repos that don't carry the object.

* refactor(ipfs): improve formatting and readability in get_by_cid function

✓ P3 blocker fixed: cargo fmt applied — the format gate will pass.

  ✓ P3 cleanup resolved: withheld_blob_oids is still used by replication code in repos.rs, so it stays.

  • P2 follow-up: Tree/commit disclosure tracked in #135 — out of scope here.

* refactor(ipfs): streamline object retrieval by separating type and content reading

* docs(ipfs): update get_by_cid comment to reflect split object retrieval

* Run cargo fmt on store.rs

* fix(bounties): add tests for claim_bounty repo-read gate (#160)

* fix(profiles): remove redundant borrow in format arg

---------

Co-authored-by: Gravirei <[email protected]>
2026-07-10 08:39:10 +08:00
GravireiandGravirei 26bc3f6987 fix(visibility): gate repo-scoped read surfaces on visibility (#120) (#157)
* fix(node): gate GET /ipfs/{cid} on reachable allowed-set, not deny-set (#126)

The IPFS visibility gate used withheld_blob_oids (a deny-set enumerating
only reachable blobs), so a dangling/unreachable blob was absent from the
set and served in cleartext to anonymous callers. Flip to an allowed-set
(allowed_blob_set_for_caller) that enumerates reachable blobs the caller
may read: a dangling blob has no path, is never in the set, and 404s.

* perf(ipfs): check object existence before allowed-blob walk

Move store::read_object before the allowed_blob_set_for_caller
spawn_blocking call so random-CID spray against repos with
path-scoped rules cannot trigger full-history git walks on
repos that don't carry the object.

* refactor(ipfs): improve formatting and readability in get_by_cid function

✓ P3 blocker fixed: cargo fmt applied — the format gate will pass.

  ✓ P3 cleanup resolved: withheld_blob_oids is still used by replication code in repos.rs, so it stays.

  • P2 follow-up: Tree/commit disclosure tracked in #135 — out of scope here.

* refactor(ipfs): streamline object retrieval by separating type and content reading

* docs(ipfs): update get_by_cid comment to reflect split object retrieval

* Run cargo fmt on store.rs

* fix(visibility): gate repo-scoped read surfaces on visibility (#120)

* fix(visibility): enhance repo read authorization checks and improve test coverage

* Fix review feedback: clamp limit, memoize authz, identical 404 body, resolved issue id in comments

* cargo update -p crossbeam-epoch: fix RUSTSEC-2026-0204 (invalid pointer dereference)

* Fix review findings: correct authz memoization, page past hidden bounties, gate star/unstar

* Fix findings: sign CLI/MCP reads, keyset pagination, gate claim_bounty

---------

Co-authored-by: Gravirei <[email protected]>
2026-07-08 08:46:19 +08:00
beardthelionandt d00d89ae5b fix(node): gate POST /api/v1/sync/trigger and rate-limit the peer-sync routes (#82) (#161)
* feat(node): add per-client-IP limiters + knobs for the peer-sync routes (#82)

Plumb two dedicated per-IP RateLimiters into config/state so the peer-sync
enqueue routes can be braked independently: sync_trigger_rate_limiter (tight,
GITLAWB_SYNC_TRIGGER_RATE_LIMIT, default 60/hr) and peer_write_rate_limiter
(generous, GITLAWB_PEER_WRITE_RATE_LIMIT, default 600/hr). Both are bounded and
swept by the periodic cleanup loop, keyed via the node-wide push_limiter_trust.
No route is wired to them yet; that lands next.

* fix(node): require a signature on /sync/trigger and rate-limit the peer-sync routes (#82)

/api/v1/sync/trigger was reachable unauthenticated in the default config and
drove an O(peers) outbound fan-out + per-repo sync_queue enqueue — an anonymous
amplification/DoS. The same enqueue_sink is reachable via /sync/notify, which
also accepts unsigned known-peer requests and had no rate limit.

- Move /sync/trigger into its own always-signed route group (add_auth_layers),
  independent of require_signed_peer_writes, and give it a tight per-IP brake.
- Add a separate, generous per-IP brake to the peer-write group
  (/peers/announce, /sync/notify); notify's signature behavior is unchanged
  (rolling-upgrade compat), it only gains the brake. Separate buckets so an
  unsigned notify flood can't drain the signed trigger caller's quota.
- Per-IP (not per-DID): a did:key farm self-registers, so a signature/per-DID
  gate does not cap cost (INV-10); the brake keys on the client IP before auth.

Adds an adversarial test matrix (unsigned->401 both modes, IP flood->429,
DID-agnostic brake, forged X-Forwarded-For can't bypass, notify flood->429,
distinct-IP not throttled, cross-route non-exhaustion, 0-disables-but-sig-holds,
announce still accepts unsigned).

* fix(gl): surface node denial on sync trigger and sanitize the error body (#82)

Now that the node gates /api/v1/sync/trigger behind a signature and rate limit,
'gl sync trigger' must not render a 401/429 as a fabricated '✓ sync triggered /
0 peers' success. Check resp.status() before parsing; on a non-2xx, extract the
node's message and bail. Strip C0/C1 control bytes from the node-supplied string
before printing (INV-6) — a hostile node could embed ANSI/OSC escapes in the
body. Adds mockito tests: 401/429 surface as errors (RED without the status
check), control bytes are stripped, and the 200 path is unchanged.

* fix(review): bound gl error-body read, strip bidi controls, genericize 429 message (#82)

Code-review follow-ups on the #82 change:
- gl sync trigger: read the node error body with a capped reader
  (read_body_capped) instead of resp.text(), so a hostile/broken node can't
  force an unbounded allocation to surface a denial (INV-6, read half).
- sanitize_node_msg: also strip Unicode bidi/format controls (U+202A-202E,
  U+2066-2069, LRM/RLM/ALM) — is_control() only covers the Cc category, but a
  right-to-left override can reorder the displayed error line.
- too_many_requests(): the shared per-IP 429 middleware now serves the peer-sync
  routes too, so drop the push-specific wording ('rate limit exceeded'); the
  push-specific 429 in the receive-pack handler is unchanged.
Adds tests for the bidi/length sanitize and the oversized-body path.

* test(#82): close reasoned-not-run gaps with load-bearing tests

Executes the branches that were previously only reasoned:
- read_body_capped byte-cap test (RED against resp.text() — 2MB body -> ≤cap):
  vets the bounded-read fix, not just the display cap.
- sync-route 429 body asserts 'rate limit exceeded' and NOT 'push' (RED before
  the message was genericized); also finishes genericizing the warn log, which
  the response-body change had missed.
- real RFC 9421 signature through the full router reaches the handler (200) in
  both config modes — the positive path through the actual gate, not a
  direct-mount with an injected DID.
- /peers/announce flood -> 429 (the peer_write brake covers announce, not just
  notify).
- trigger_counts extraction unit test (happy + malformed -> 0,0), closing the
  count-formatting gap in the 200 path.
No production behavior change beyond the warn-log wording.

* fix(#82): require an identity for sync trigger and fix the rollout docs

Address review feedback on the always-signed /sync/trigger route.

- gl sync trigger now requires a local identity via load_keypair_from_dir and
  fails locally with "identity not found" before issuing a request, instead of
  .ok() silently sending an unsigned request that can only 401 remotely. Add a
  test proving it fails before dialing the node.
- Split /sync/trigger out of the staged unsigned peer-write rollout in the
  README and the OSS-readiness audit; only /peers/announce and /sync/notify keep
  the rolling-upgrade unsigned behavior.
- Reorder the two peer-sync rate-limit vars in .env.example for dotenv-linter.
- Bump crossbeam-epoch to 0.9.20 (RUSTSEC-2026-0204), matching main, so cargo
  audit passes.

---------

Co-authored-by: t <t@t>
2026-07-08 08:44:55 +08:00
beardthelionandt 67ad2b876c fix(deps): bump crossbeam-epoch to 0.9.20 for RUSTSEC-2026-0204 (#162) (#163)
RUSTSEC-2026-0204 (invalid pointer dereference in crossbeam-epoch's
fmt::Pointer impl for Atomic/Shared) hard-fails cargo audit on every branch,
since the advisory's addition to the RustSec db. crossbeam-epoch is a fully
transitive dependency (moka -> hickory-resolver -> libp2p-dns), and the
advisory's prescribed fix is >= 0.9.20; moka 0.12.15 accepts >=0.9.18,<0.10.0
so the bump resolves with no other graph changes.

Lockfile-only, single crate. Verified: cargo audit exits 0 and the workspace
builds clean with 0.9.20.

Closes #162.

Co-authored-by: t <t@t>
2026-07-07 22:46:44 +08:00
Kevin CodexandClaude Fable 5 6cff5286b4 fix(release): build aarch64-musl natively on arm64 runners, replace retired macos-13 (#155)
The aarch64-unknown-linux-musl leg cross-compiled a musl target with the
glibc toolchain (CC=aarch64-linux-gnu-gcc), which aws-lc-sys cannot build
with — the Build step failed on every release and no aarch64-linux asset
has ever shipped. Build it natively on ubuntu-24.04-arm instead (free for
public repos), using the same stock musl-tools recipe as the x86_64 leg,
and drop the smoke-test skip since the binaries now run on their build
runner.

The x86_64-apple-darwin leg targeted macos-13, which GitHub has retired;
the job queues forever and holds the release concurrency group open. Move
it to macos-15-intel.

Both failures also skipped the downstream npm-publish, homebrew-bump, and
web-sync jobs, so v0.4.0 and v0.5.0 were never published there.

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-06 10:39:12 +08:00
github-actions[bot] 5f63f28215 chore(main): release 0.5.0 (#142)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
v0.5.0
2026-07-05 20:07:23 +08:00
Kevin CodexandClaude Opus 4.8 06388ec26a feat(gl): sanctioned iCaptcha client flow + secure git lifecycle (#138)
* feat(gl): sanctioned iCaptcha client flow + secure git lifecycle

Builds the client side of the iCaptcha gate so a legitimate agent passes the
challenge transparently, with no manual headers and no GITLAWB_ICAPTCHA_PROOF
env var. Server enforcement is unchanged.

New crate `icaptcha-client` (blocking, shared by gl + the git helper):
- deterministic solvers for the computational challenge types (arithmetic,
  algebra, sequence) matching the iCaptcha generators; anagram/logic/LLM fall
  back to a solver hook / interactive prompt.
- obtain_proof(): POST /v1/challenge (requesterId = caller DID), solve, POST
  /v1/answer, handle escalation, return the signed proof. Optional bearer auth
  via GITLAWB_ICAPTCHA_API_KEY.

gl (crates/gl):
- http.rs: unify post/put/delete through one signed sender that, on a 403
  iCaptcha challenge (detected via x-icaptcha-url / x-icaptcha-level headers),
  solves it and retries the same signed request with the x-icaptcha-proof
  header (bounded retry absorbs the ~5 min proof TTL). Removes the env hack.
  Emits an actionable hint on 401 not_an_agent (old-CLI / unregistered).
- repo.rs: `repo info` checks status and returns a real 404 instead of a stub
  card with `?` fields; `repo info`/`clone` resolve the owner from the local
  identity or an explicit owner/name — never the node's own DID.
- doctor.rs: warn on gl-vs-node version drift; add an iCaptcha reachability
  check.

git-remote-gitlawb: same shared client — push stays signed (RFC 9421); on a 403
iCaptcha (safety net; push is signed-only, not gated) it solves and retries.
Clone/fetch unsigned; missing repo keeps its clear 404.

gitlawb-node: the 403 icaptcha_proof_required response now advertises the
service url + required level as JSON fields and x-icaptcha-url / x-icaptcha-level
headers (mirroring the human_detected pattern) so clients discover them instead
of scraping the message. The sub == authenticated-DID and expiry/level checks
are unchanged — enforcement is not weakened.

README: documents the full lifecycle (identity -> register -> repo create with
auto-iCaptcha -> push -> clone), the requesterId == DID rule, and proof TTL.

Tested: solver unit tests; full workspace green against Postgres (node still
rejects missing/expired/wrong-subject proofs).

* fix(icaptcha-client,gl): address PR #138 review feedback

Rebased onto main (resolving the git-remote-gitlawb/main.rs conflict from #119)
and addressed the review findings:

- [beardthelion P2 / jatmn P2 / CodeRabbit] Removed the dead iCaptcha retry loop
  from the push path. git_receive_pack never calls verify_request (only
  create/fork/register are iCaptcha-gated), so a push 403 never carries
  x-icaptcha-* headers and the loop could never solve. Taking main's push path
  (via #119) deletes it, which also resolves the pack-clone (retries no longer
  copy the whole pack) and the interactive-prompt-over-git-stream threads, and
  restores the sanitized info/refs error path. git-remote-gitlawb no longer
  depends on icaptcha-client.

- [beardthelion P1] Sanitize and length-bound iCaptcha error bodies before they
  reach the terminal. The origin is only as trusted as the node that advertised
  it, so its non-2xx bodies (and Failed{reason}) are attacker-influenceable; they
  went raw into bail! (CWE-150, the #137 class). Now bounded-read + C0/C1-stripped
  + capped, same shape as #137.

- [beardthelion P2] Do not trust or hand credentials to a node-chosen iCaptcha
  URL. x-icaptcha-url was used as the base with no scheme/host check and the API
  key was sent to it on the first hop (SSRF + key exfiltration). resolve_solver_url
  now honors an advertised URL only when https + host-allowlisted (public default
  or the operator's GITLAWB_ICAPTCHA_URL), else falls back to the trusted origin;
  the API key is attached only to the operator's own configured origin.

- [jatmn P2 / CodeRabbit] gl repo clone with a bare name now derives the short
  owner key via resolve_owner_did, matching the other commands' gitlawb://z.../name
  URL shape instead of the colon-bearing full DID.

Skipped (with reason): jatmn P3 (doctor GITLAWB_ICAPTCHA_URL) is resolved by the
P2 change — that env var is now read by the write path, so the doctor's probe is
accurate. CodeRabbit User-Agent-version nit lived in the push code reverted to
main. README push wording kept ("signed-only, no per-push challenge") — now
accurate since the retry path is gone; the API-key bullet was updated for the new
trust model.

Tests: added sanitize + resolve_solver_url unit tests; full workspace green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-07-05 19:47:06 +08:00
2df6ff9d30 fix(node): close two spam-vector root causes (trust upsert + ungated push) (#152)
* fix(node): close two spam-vector root causes (trust upsert + ungated push)

The June 2026 spam wave kept re-materializing after cleanup because two
paths let an unauthenticated-by-captcha actor keep operating:

1. `update_trust_score` was an upsert. Any authenticated push/issue/PR
   from a DID with no agent row silently INSERTed one with a fresh
   `registered_at`, bypassing the iCaptcha gate on /api/register — so
   deregistered spam DIDs came back the moment they pushed. Make it an
   UPDATE-only no-op for unregistered DIDs; registration is the sole way
   into the agents table. Regression test added.

2. `git-receive-pack` had no rate limit. The per-DID limiter on the
   creation routes can't brake a push flood from a DID farm (one
   throwaway identity per repo), so the node absorbed several pushes/sec
   per IP, each a Tigris round-trip + peer-notify fan-out. Add a per-
   client-IP limiter (Fly-Client-IP / X-Forwarded-For) on the push path,
   default 600/h, `GITLAWB_PUSH_RATE_LIMIT` override, 0 disables. Fails
   open without a proxy header so self-hosted nodes aren't broken.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* fix(node): address PR review — advertisement throttle, trusted-proxy IP key, bounded limiter

Resolves the review findings on the push rate limiter:

P1 — advertisement path bypassed the limiter. A push hits
GET info/refs?service=git-receive-pack first, which calls acquire_fresh()
(a Tigris download); it sat in git_read_routes under only optional_signature,
so the flood was reachable via the cheap GET without ever sending the throttled
POST. Now git_info_refs applies the same per-IP brake before the fresh acquire.

P1 — client-supplied forwarded headers were trusted. The key came from
Fly-Client-IP / the first X-Forwarded-For hop, both client-settable, so a
flooder could rotate the header and never fill a bucket. Replaced with a
TrustedProxy policy (GITLAWB_TRUSTED_PROXY = fly | x-forwarded-for | unset):
trust only the operator's edge header, fall back to the real socket peer
(ConnectInfo) otherwise. Fly config trusts Fly-Client-IP; the AWS/Caddy
compose trusts the rightmost X-Forwarded-For hop.

P2 — unbounded limiter key set / insert-before-reject. Added a max_keys cap
(reject-before-insert, inline eviction when full) so a varied key cannot grow
the map, and a rejected request never allocates. Applied to both the per-IP
and per-DID limiters.

P2 — malformed forwarded header disabled the brake. Empty leading XFF hop
returned None and skipped the limiter; empty Fly-Client-IP returned Some("").
client_key now takes the rightmost XFF hop and always falls back to the peer,
so a malformed header can never turn the limiter off.

P2 — missing tests. Added: middleware 429 path, per-peer isolation, key-cap
eviction/rejection, trusted-proxy header resolution, and an integration test
asserting the receive-pack advertisement is throttled before the acquire.

Also serve with into_make_service_with_connect_info so the peer address is
available. New env: GITLAWB_PUSH_RATE_LIMIT, GITLAWB_TRUSTED_PROXY (documented
in .env.example).

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Update infra/aws/compose.yaml.tftpl

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fix(node): amortize the rate limiter's capacity eviction scan

`RateLimiter::check` ran a full O(max_keys) `retain` sweep on every new-key
miss once the map was at capacity — which is exactly the distinct-key-flood
state the cap defends against, so the limiter serialized all traffic behind a
200k-entry scan per request under the mutex.

Gate the inline sweep to run at most once per `sweep_interval` (min of the
window and 1s), tracked by `last_sweep`. Between sweeps a new key is rejected
without scanning; the background `cleanup()` loop still reclaims independently.
The fast path for existing keys is unchanged, the key cap invariant holds
(map never exceeds max_keys, rejected keys never allocate), and post-flood
self-healing stays bounded to one interval. Added a test asserting a burst of
capacity misses does not trigger repeated eviction scans.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-07-05 17:31:21 +08:00
Mizuki Hayashiandmizuki0x 2620e973e3 fix(repos): log the cause when repo create fails (#103)
create_repo was the one git handler that mapped its error without logging
it (info_refs, receive_pack, acquire all log with repo context). So a
failed init_bare returned an opaque 500 "initializing bare repo" with no
server-side trail to the real cause.

Log the full anyhow chain ({:#}, so the leaf git/filesystem error is kept)
with owner+repo context, matching the other handlers. Response unchanged.

Co-authored-by: mizuki0x <[email protected]>
2026-07-05 17:16:11 +08:00
beardthelionandt e3a20317d5 ci: pin cargo-audit on the blocking gate, float the weekly canary (#151)
The PR-blocking audit job ran `cargo install --locked cargo-audit` with no
version, so the scanner floated to the latest release on every run; a
regressed release could red the PR gate repo-wide with no code change. Pin it
to 0.22.2, the version the unpinned install currently resolves to and the one
the audit-gate learnings were validated against. `cargo install --version X`
is an exact pin (not a caret), so this freezes the binary while the advisory
DB is still fetched fresh at scan time, which is what the gate actually keys on.

Keep a forcing function so the pin cannot silently rot: leave the weekly
Scheduled Audit deliberately unpinned as a drift canary. That job is cron-only
(never pull_request/push-triggered), so it never gates a merge; floating there
surfaces a regressed latest as a red weekly run or a changed report, which is
the drift signal, without ever blocking a PR.

Also assert the installed version after the pinned install so an accidental
future unpin fails loud instead of silently running a different scanner,
include the version in the rust-cache key so cache hits stay deterministic,
and document the yank/bump path in the step comment.

Co-authored-by: t <t@t>
2026-07-05 17:10:45 +08:00
beardthelionandt c296e23678 test(node,git): wire run_git_service teardown end-to-end (#62) (#150)
The #61 tests build KillGroupOnDrop by hand and never call
run_git_service, so dropping process_group(0), the guard-arming, or the
post-reap disarm left them green. Add two tests that drive the real
run_git_service through an injected fake git (a minimal git_bin seam;
production passes "git") and assert the production spawn path actually
wires the teardown:

- future-drop mid-flight SIGTERMs the whole group, reaping git and its
  grandchild (RED without process_group(0) or the guard-arming)
- a completed request disarms the guard, leaving a live grandchild
  untouched (RED without the post-reap disarm)

Each assertion was shown RED against its mutation and green when
restored. Parallel-safe: the fake is reached via the seam, not a PATH
mutation.

Co-authored-by: t <t@t>
2026-07-05 17:10:25 +08:00
beardthelionandt 20d6848846 fix(gl): sign the CLI's /ipfs/pins reads under the #134 auth gate (#146)
* fix(gl,ipfs): sign gl ipfs list so it works under the #134 pins auth gate

/api/v1/ipfs/pins now 401s anonymous callers (#134). Load the caller's
identity (--dir, default keystore) and use NodeClient::get_signed; when no
identity exists, propagate load_keypair_from_dir's existing 'gl identity new'
error instead of a raw 401. Tests: signed-headers present, empty, and
no-identity-issues-no-request (mockito).

* fix(gl,node): sign the gl node status pins panel, degrade gracefully

The pins panel signs its /api/v1/ipfs/pins read (#134 gates it behind auth)
via an injectable fetch_pins helper with four states: pins, empty,
unavailable (signed read rejected/errored), and needs-identity (no keypair,
no request issued). cmd_status loads the identity gracefully so a missing
keystore never aborts status; peers/repos/p2p/events panels stay anonymous.
Tests drive fetch_pins directly against a mock node.

* fix(gl): address review — surface pins HTTP errors, correct empty-panel label

- gl ipfs list: check the /ipfs/pins response status before parsing, so a
  rejected signed read (e.g. 401) surfaces as an error instead of silently
  printing 'No IPFS pins recorded'. Matches the sibling gl ipfs get.
- gl node status: PinsPanel::Empty now renders 'Pinned CIDs: 0' (a reachable
  node with zero pins), reserving 'unavailable' for the failure state; the
  prior label reused 'IPFS not configured' and misreported the zero-pins case.
- Carry the resolved count in PinsPanel::Pins, removing the duplicated
  count-fallback closure between fetch_pins and the render arm.

* test(gl,node): cover fetch_pins error branches and pins-panel rendering

Extract the pins-panel render into a testable pins_status_line helper and
assert all four states' output (closes the untested cmd_status render path,
including the empty->'Pinned CIDs: 0' label). Add fetch_pins tests for the
two error branches that were code-traced but unexecuted: a malformed 200
body and a transport error both degrade to Unavailable without panicking.

* style(gl): rustfmt the ipfs list pins-response parse

* fix(gl,node): honor --dir identity selector in `gl node status` (#146)

`gl node status` always loaded the default keystore via
`load_keypair_from_dir(None)`, so a user who created or selected an identity
with `--dir` saw the status pins panel render "sign in to view" even though
`gl ipfs list --dir` authenticated fine. Add a `--dir` option to
`NodeCmd::Status` and thread it into the signed pins fetch, so both CLI pins
callers select the same identity directory.

An explicit `--dir` that can't be loaded (missing, corrupt, or unreadable) no
longer masquerades as "no identity, run `gl identity new`": the pins identity
is resolved up front into Keyed / Anonymous / DirUnusable, and the unusable
explicit-dir case renders a distinct "no usable identity in <dir>" panel line
that names the path, while a missing default keystore (no `--dir`) still
degrades quietly. A pins failure never aborts the dashboard.

Tests cover both flag branches (Some/None) and the resolver both ways: a good
`--dir` loads Keyed, an unusable one reports DirUnusable/IdentityError without
issuing a request. The new symbols were absent before, so they compile-RED
first.

* test(gl,node): pin the pins-identity resolver to the --dir key

Follow-up test hardening on the #146 --dir wiring after review:

- resolve_pins_auth_loads_keyed_identity_from_explicit_dir now destructures
  Keyed and asserts loaded.did() equals the --dir key's DID. A bare Keyed(_)
  match passed even when the load fell through to the default keystore; the
  DID assertion catches that regression (confirmed red under a simulated
  fallthrough, green on the real code).
- add resolve_pins_auth_none_is_never_dir_unusable: an absent --dir must
  degrade to Anonymous or Keyed, never DirUnusable. It asserts only the
  None to DirUnusable mapping, so it is stable whether or not the test
  runner has a default ~/.gitlawb identity.

Test module only; no production behavior change.

---------

Co-authored-by: t <t@t>
2026-07-05 17:10:02 +08:00
beardthelionandt 4891db3889 fix(node): gate the ref-updates feeds on read visibility (#112, #114) (#143)
* feat(node,visibility): add pure fail-closed feed-visibility filter (#112,#114)

Add ref_update_row_visible: given the deduped local repo set + their
visibility rules, decide whether a received_ref_updates row (keyed by its
peer-supplied slug) is visible to the caller. Matches slug->repo by name
plus prefix-tolerant owner key, and drops any row matching a local repo
the caller cannot read at root; rows matching no local repo (remote/
gossip-only) and malformed slugs pass through. Fail-closed by
construction (no default-keep). Pure/I-O-free so visibility.rs stays
exhaustively unit-tested.

Shared gate for the GraphQL (#112) and REST (#114) ref-updates feeds;
call sites land in the following commits. Includes a DB-layer test
pinning that a private canonical row beats a public mirror row in
list_all_repos_deduped (else the feed gate would over-serve).

* fix(node,graphql): gate refUpdates on read visibility (#112)

The GraphQL ref_updates resolver mapped every received_ref_updates row
straight out with no visibility gate, so an anonymous caller could read
a private repo's ref metadata (refName, SHAs, pusherDid, nodeDid) by
name. Thread the caller DID and retain rows through the shared
ref_update_row_visible filter (deduped local set + rules, same '/'
decision the repos resolver uses); applies to both the repo:Some and
repo:None branches. Remote/gossip-only rows still pass.

Removes the now-live filter's dead_code allow. Tested via the GraphQL
schema: anon leak reproduced before the gate (RED) and closed after
(GREEN); owner still sees their private row; full-DID and truncated-key
slug aliases fail closed; remote rows kept.

* fix(node,events): gate global ref-updates feed on read visibility (#114)

GET /api/v1/events/ref-updates returned every received_ref_updates row
across all repos, including private ones, to any anonymous caller (the
REST analog of #112). Thread the caller DID via optional_signature and
retain rows through the shared ref_update_row_visible filter, deriving
count from the filtered set. Keeps the crate::error::Result return type;
the deduped/rules loads fail closed (500) on a DB error rather than
serving unfiltered. Remote/gossip-only rows still pass.

Tested via the crate's HTTP-API harness: anon leak reproduced before the
gate (RED) and closed after (GREEN); owner still sees their private row;
mixed feed yields only public rows with a filtered count; full-DID and
truncated-key slug aliases fail closed; remote rows kept.

* fix(node,graphql): gate the ref-update subscription broadcast on announce (#112,#114)

Code review surfaced a third reader of the same ref metadata the query
and REST feeds gate: the GraphQL subscription. /graphql/ws is mounted
after the optional_signature layer (no caller to gate against), and the
push handler broadcast every ref update to it unconditionally — outside
the if-announce block that already gates the sibling gossip and Arweave
sends. So an anonymous websocket subscriber received live ref metadata
(repo, refs, SHAs, pusher/node DID) for private repos as they were
pushed — the subscription analog of the #112/#114 leak.

Move the ref_update_tx broadcast inside the existing if-announce guard so
only publicly-readable pushes reach the unauthenticated subscription,
matching gossip and Arweave. Pin the gate decision with a
replication_withheld_set test (announce false for private, true for
public).

Also harden the feed filter's owner-key normalization: normalize the
slug owner to the last ':'-segment (same rule as record_key) instead of
stripping only did🔑, so a multi-segment DID (did:web) and its
full-DID slug match and fail closed rather than over-serving. Regression
test added (RED under the old normalization).

* docs(node,graphql): pin the subscription write-side gate invariant; add did:web keep test

Code review flagged that the unauthenticated /graphql/ws ref_updates
subscription now depends on a single-point invariant: safety rests
entirely on every sender to ref_update_tx being announce-gated, since the
resolver has no caller to gate against. Document that on the resolver so
a future sender can't silently reopen the leak.

Add the symmetric keep-side test for the did:web owner-key normalization
(public multi-segment-DID repo row is kept for anon), so a regression
that over-drops legitimate did:web rows is caught alongside the existing
drop-side test.

* docs(node,visibility): correct ref-update slug normalization rationale (#112,#114)

The comment claimed parity with did_matches/DEDUP_CTE, but the last-segment
normalization deliberately diverges: the emitter (publish_ref_update) broadcasts
the short, method-stripped slug, so a repo's own rows arrive keyed by the
trailing segment and the gate must match on it to stay fail-closed. Reusing the
did:key-aware keep-whole rule would fail open on a did:web repo's short slug.

Add feed_private_didweb_short_slug_dropped_for_anon to pin that load-bearing
canonical case; relabel the full-DID test as the non-canonical hand-forged form.

* fix(node,events): gate ref-update feeds before the limit via paging (#114)

The REST global feed and the GraphQL ref_updates resolver loaded `limit` rows
and dropped private ones in memory, so an anonymous request could get fewer than
`limit` events (or zero) when the newest rows named private local repos, even
though older public ref updates existed.

Add a shared collect_visible_ref_updates helper that applies the fail-closed
gate before the limit: it over-fetches in bounded pages (128 rows, capped at
max(limit, 2048) scanned) until `limit` visible rows are collected or the table
is exhausted. Both feeds route through it so the one gate cannot drift. Back it
with list_ref_updates_page (stable timestamp,id ordering for offset paging) and
drop the now-unused list_ref_updates / list_ref_updates_filtered. The page size
is a parameter on an inner fn so a regression can exercise multi-page paging.

Regressions: newest-private, older-public, small limit still returns the latest
visible rows on both surfaces (RED before, GREEN after); and a page=2 test pins
offset paging across page boundaries (no skip or duplicate).

* docs(node,visibility): correct slug-builder attribution in ref-update gate comment

The comment credited publish_ref_update with building the wire slug, but that
p2p method only broadcasts a pre-built RefUpdateEvent; git_receive_pack assembles
the {last-segment}/{name} slug and hands it over. Fix the attribution.

* fix(node,events): bound feed limit and withhold quarantined mirrors (#114)

Resolve two CodeRabbit findings on the ref-update feeds:

- Clamp the effective limit inside the shared collector (max 200). REST already
  capped, but the GraphQL resolver passed its caller-provided limit uncapped,
  letting a large request return unbounded rows and scan unbounded DB rows. Both
  surfaces are now bounded identically.

- Fold quarantined mirrors into the gate's match universe as unreadable.
  list_all_repos_deduped excludes quarantined rows, so a ref-update naming a
  quarantined local mirror matched nothing, was misclassified as remote, and was
  served to anon, bypassing the withheld-from-every-listing-surface contract. Add
  list_quarantined_repos and drop matching rows fail-closed.

Regressions: oversized-limit clamp (201 rows -> 200) and quarantined-mirror
withheld-from-anon, both RED before, GREEN after.

* fix(node,events): gate the repo-scoped events feed on read visibility (#112, #114)

GET /api/v1/repos/{owner}/{repo}/events (list_repo_events) served a repo's ref
certificates and received gossip ref-updates with no caller identity and no
visibility check, so an anonymous caller who guessed a private repo's owner/name
read its ref metadata. It was the last repo-scoped reader in the #112/#114
ref-metadata leak class.

Gate it in two layers, using the caller DID the optional_signature layer already
supplies:
- The cert half is gated by a repo-root authorize_repo_read (keyed by the unique
  repo record id): a denied, quarantined, or not-hosted repo returns an opaque 404
  (RepoNotFound), never a 403.
- The gossip half is filtered per row through the shared collect_visible_ref_updates.
  received_ref_updates rows are keyed by the lossy, non-unique wire slug
  {last-segment}/{name}, so two owners (did:web:a:alice, did:web:b:alice) collide on
  `alice/name` and a plain slug query would serve a colliding private repo's rows to
  anyone allowed to read this one; the per-row gate drops them fail-closed.

Both halves now propagate DB errors as 500 instead of swallowing them into an empty
200. Removes the now-unused list_repo_ref_updates.

Deliberate behavior change: a repo this node does not host returns 404 (it holds no
visibility record for it and fails closed; remote gossip is read via the global
/api/v1/events/ref-updates feed). Drops list_repo_events from the egress-guard
KNOWN_UNGATED allowlist; the source-scrape staleness assert enforces the removal.

Tests cover anon-private, owner-private (both datasets), anon/authed-public,
quarantined, released-from-quarantine, non-owner, not-hosted, did:web (owner and
anon), the slug collision, oversized-limit clamping, and DB-error-fails-closed on
both the gate and the cert fetch.

* fix(node,events): clamp negative ref-update feed limits to zero (#114)

A caller-supplied `?limit=-1` cleared the `.min(MAX_VISIBLE_REF_UPDATES)`
upper cap unchanged and reached `all_events.truncate(limit as usize)` in
list_repo_events, where `-1 as usize` is usize::MAX, so the truncate was a
no-op and the handler returned the full unbounded local ref-cert set.

Floor both feed handlers' limit parse with `.clamp(0, MAX_VISIBLE_REF_UPDATES)`.
Only the repo handler's clamp is load-bearing: it guards the local `truncate`,
which does not pass through the shared collector. The global feed's clamp is a
consistency measure (its limit is re-clamped in the collector, whose want==0
short-circuits before any scan), kept in lockstep so the two per-handler caps
the module comment calls out don't drift.

Tests, both load-bearing by execution:
- repo_events_negative_limit_clamped: seeds local certs (the vulnerable half),
  RED (3) before the clamp and green (0) after.
- feed_negative_limit_returns_empty: seeds 5 visible rows; green (0) with the
  collector clamp, 5 without it, proving the collector is the global feed's guard.

* fix(node,events): drop quarantined repos before the feed visibility gate (#114)

The ref-update feeds folded quarantined mirrors into the deduped repo set with is_public=false and relied on the row visibility gate to withhold them. That held for anonymous callers but not for one matching the mirror's owner_did: visibility_check short-circuits to Allow for the owner before is_public is read, so an owner-matched quarantined row was served on both the REST global feed and the GraphQL ref_updates resolver.

Drop any row that names a quarantined repo in the shared collector, before the visibility gate runs, so the drop bypasses the owner short-circuit. Quarantine is a status decided at admission and checked separately from the mirror's visibility fields, so it must deny every caller including the owner.

Extract the slug match into one ref_update_row_names_repo predicate reused by both the gate and the quarantine drop, so the two cannot disagree about which rows a repo owns.

Tests cover the owner case at the collector, REST handler, and GraphQL resolver in both bare-key and full-did:key owner forms, plus a must-not case proving the drop withholds only quarantined rows while still serving unrelated visible ones.

* test(node,events): align did:web feed tests with the #141 canonical slug

The rebase onto main adopts #141's normalize_owner_key on the repo-scoped
ref-update read path, which keeps a non-did:key DID intact instead of taking
the last URL segment. Align the two did:web fixtures that still assumed the
old {last-segment}/{name} wire slug:

- repo_events_did_web_owner_reads_own_gossip stored "alice/widget", but the
  emit side (api/repos) publishes "did:web:example.com:alice/widget", so the
  read query no longer matched and the owner saw only the cert half. Store the
  emit-side slug so the gossip KEEP branch is exercised again.

- repo_events_gossip_slug_collision_withheld_from_anon is removed. Its premise
  was that two did:web owners collide on the last-segment slug; #141 gives them
  distinct full-DID slugs, so the repo-scoped query fetches zero rows and the
  per-row drop is never reached (verified by probe: fetched=0). Global-feed
  collision coverage stays in feed_full_did_slug_dropped_for_anon and
  feed_truncated_key_slug_dropped_for_anon.

* docs(node): correct ref-update slug comments for the #141 canonical form

The rebase onto #141 changed the emit-side wire slug from the last-segment
form to normalize_owner_key (bare short key for a single-segment did:key,
full DID otherwise). Four comments still described the old behavior:

- visibility.rs ref_update_row_names_repo: the emitter builds the slug via
  normalize_owner_key, not {last-segment}/{name}. The next_back reduction on
  the matcher is what tolerates the untrusted/crafted short form; it does not
  mirror an already-stripped emit form.
- events.rs list_repo_events gossip half: the residual collision the per-row
  filter still guards is a did:key canonical owner vs its bare short-key
  mirror (both normalize to the same slug), not two did:web owners (#141
  gives those distinct slugs).
- the two did:web feed-visibility tests had their canonical/crafted framing
  backwards: post-#141 the full-DID slug is the emit form and the short slug
  is the crafted one.

Comment-only; no behavior change.

* test(node,events): add load-bearing two-repo owner-key collision deny

A public bare-key mirror (z6MkX) and a private did:key canonical repo
(did:key:z6MkX) normalize to the same owner key post-#141, so their gossip
rows share the z6MkX/... slug space on the untrusted wire. Assert an anon
global-feed read sees the public mirror's row but NOT the private canonical
sibling's: the shared gate keys on the full slug (owner + name), so a
readable public repo under an owner key does not unlock that owner's other
private repos' rows. The removed repo-scoped did:web collision test never
covered this pair.

Verified load-bearing: disabling the per-row visibility drop serves
z6MkX/secret to anon (RED); the gate drops it (GREEN).

* fix(node,events): keyset-paginate the ref-update feed collector

collect_visible_ref_updates paged received_ref_updates with LIMIT/OFFSET,
so a concurrent gossip/notify insert (which sorts to the front under
timestamp DESC) shifted every later offset and let one feed response
duplicate or skip a visible row. Switch the collector to a keyset cursor
on (timestamp, id): each page reads rows strictly older than the last row
fetched, so a newer insert lands above the window and cannot shift it.

One collector feeds the REST global, REST per-repo, and GraphQL paths, so
this covers all three; there is no client-facing cursor, so the internal
cursor tracks the last fetched row. Adds tests that OFFSET duplicates a row
under a concurrent insert and that keyset stays stable.

---------

Co-authored-by: t <t@t>
2026-07-05 17:09:15 +08:00
beardthelionandt 6f36fc07b8 fix(node,git-remote): gate receive-pack advertisement, sign client fetch/push (#119)
* fix(node,git-remote): gate receive-pack advertisement, sign client fetch/push

The info/refs ref advertisement was gated only for service=git-upload-pack
(the visibility check sat inside `if service == "git-upload-pack"`), so an
anonymous GET /{owner}/{repo}.git/info/refs?service=git-receive-pack returned a
private repo's branch names and commit tips, bypassing the read gate. Run the
visibility "/" check for both services.

Now that the advertisement is gated for both services, git-remote-gitlawb has to
authenticate its own requests, or a private repo's owner can neither fetch nor
push it. Sign the Phase-1 advertisement GET and the Phase-2 pack POST for both
services over path_and_query when an identity keypair is present; public repos
stay anonymous. This also repairs a pre-existing break where an owner could not
fetch their own private repo, because the upload-pack POST was sent unsigned.

Tests: node-side gate (anon denied, owner-signed cleared, both services); a
real-signature seam proving the client's own signature verifies under the node's
verification primitives; real served-content withholding against an on-disk repo
(the owner gets the ref bytes, a denied caller's 404 omits them); and mockito
coverage that the client attaches and omits signatures correctly.

* docs(node,git-remote): correct auth-contract comments on receive-pack gating (#119)

receive-pack POST is signature-required and separately owner-gated, not
visibility_check-gated; push implies read, not read-subset-of-push.

* test(node): assert unsigned receive-pack POST is rejected (#119)

No test covered the signature-required half of the receive-pack auth
contract. Wire the POST route behind require_signature as production does and
assert an unsigned POST returns 401 before reaching the handler; 401 (not the
handler's 404/500) proves require_signature rejected it pre-handler.

* test(node): RAII-clean the served-refs test's temp dirs on panic (#119)

advertisement_serves_real_refs_only_to_authorized_callers created /tmp/<slug>/<name>.git and /tmp/gl-seam-src-<short> and only removed them after the final assertion, leaking those dirs on the runner when an earlier assertion failed. Wrap each known path in a DirGuard whose Drop calls remove_dir_all, bound right after creation, so cleanup runs on success or panic. tempfile::TempDir does not fit here because repo_store::for_testing fixes the path layout.

* test(node): drop git_info_refs from KNOWN_UNGATED now that #119 gates it

The repo-scoped egress guard's allowlist (added in #122, merged to main
after this branch was opened) still listed git_info_refs as ungated. This
branch makes the info/refs visibility gate unconditional for both
git-upload-pack and git-receive-pack, so the handler is now gated and the
staleness assert requires removing it from the allowlist.

* style(node,git-remote): tidy #119 review nits (em dashes, dead binding, test header)

Post-rebase ce-code-review pass on #119. No behavior change:
- replace em dashes in the added comments with commas/colons/parentheses
- drop the dead `let _ = COVERED_COMPONENTS;` binding (and its import); the
  `missing_components().is_empty()` assert already covers the intent
- add a section header above the new git-info-refs test block to match the
  module's sectioning convention

* test(node): recurse nested api modules in the egress-gate guard

The completeness scan in every_repo_scoped_handler_is_gated used a flat
read_dir over src/api, so a future api/<module>/mod.rs could add a
repo-scoped handler the scrape never inspects. Walk the api tree recursively
via a new collect_rs_files helper (covered by its own unit test), skipping
only the top-level mod.rs so nested module files are checked too.

* fix(git-remote): keep public fetches anonymous, sign only on a 404 retry (#119)

git-remote-gitlawb signed every git-upload-pack request whenever an identity
existed, disclosing the caller's DID to any node it fetched a public repo from,
even though public repos need no auth. Fetch now sends the advertisement GET
anonymously and escalates to a single signed retry only when the node denies it
with 404 (how it withholds a private repo from an unauthenticated reader); if the
retry was needed, the upload-pack POST is signed too. Push (git-receive-pack) is
unchanged and signs from the first request, since a push can never be anonymous.

Verified against the node by execution: an unsigned private fetch returns 404,
not 401, and a signed retry clears the read gate for the owner and for a granted
non-owner reader, while a signed non-reader stays 404.

Tests drive handle_connect end to end (recording mock): public fetch stays
anonymous for GET and POST, private fetch retries signed once and signs the POST,
a keyless fetch does not retry, a still-denied signed retry surfaces 404 once, and
push signs from the first request.

* fix(node): scan nested api sources by relative path in the egress guard (#119)

The egress-gate completeness scan skipped already-covered files by basename
(listed.contains(fname)), so a nested api/<module>/repos.rs would be skipped
because the top-level repos.rs is listed, letting a future nested module add an
ungated repo-scoped handler the scrape never inspects. Extract the skip logic into
unlisted_source_files and match on the path relative to api_root, so only the exact
top-level files already covered are skipped and a nested file with a colliding
basename is still scanned. Completes CodeRabbit's outstanding request.

---------

Co-authored-by: t <t@t>
2026-07-05 17:08:44 +08:00
beardthelion 563c456803 fix(node,gossip): route gossip HTTP through the no-redirect client (#93) (#140)
gossip_task built its own reqwest::Client::new(), whose default redirect
policy follows up to 10 redirects, and used it for the bootstrap announce
and the periodic peer /health ping. A peer host (public at announce time,
per is_public_http_url) could answer /health with 302 Location:
http://169.254.169.254/... and the node would follow it into cloud
metadata or internal addresses. Blind SSRF: only request status feeds
mark_peer_ping.

Reuse the shared state.http_client (Policy::none, already on AppState),
matching sync.rs and the announce fan-out hardened in #78. Extract
ping_peer_health so the redirect-not-followed behavior is covered by a
mockito test (302 to an internal target registered expect(0), asserted
not hit).
2026-07-03 08:13:20 +08:00
GravireiandGravirei 6c95592d18 fix(node): prefer canonical repo row over mirror row in get_repo (#124) (#141)
* fix(node): prefer canonical repo row over mirror row in get_repo (#124)

get_repo matched both mirror rows (bare short-DID, is_public=true, no
visibility rules) and canonical rows (full did🔑 DID, correct rules),
returning one nondeterministically. When the mirror row won, the
visibility gate saw is_public=true + empty rules, allowing unauthenticated
read of private/withheld content.

Add ORDER BY to prefer canonical rows (UUID id, no slash) over mirror
rows (slash-form id), matching the ranking logic already used by
DEDUP_CTE and dedupe_canonical_repos.

Test seeds mirror FIRST so an unordered fetch_optional returns the
mirror row — proving the test locks in the fix.

* fix(test): date canonical row after mirror to guard CASE term in get_repo fix (#124)

* fix(node): tighten get_repo owner matching to did:key only, not any DID method (#124 P2)

* fix(node): refactor owner DID normalization to a dedicated function in create_repo

* fix(node): align clone URL and slug construction with tightened did:key matching

- Make normalize_owner_key pub(crate) and reuse it in api/repos, api/events, api/peers, and visibility modules.

- Add read-gate authorize_repo_read and to_response clone_url slug tests.

* fix(node): hoist owner-key CASE into shared const + address #124 review feedback

- Hoist 7 verbatim SQL CASE copies into OWNER_KEY_CASE_SQL const
  (dedup_cte(), get_repo, count_repos_deduped), keeping one literal
  copy in the v7 migration with a cross-reference comment.
- Add 9 unit tests for normalize_owner_key over the full boundary set.
- Add LIMIT 1 to get_repo query for single-row intent.
- Add non_key_owner_bare_short_does_not_match is_owner regression test.
- Update is_owner doc to reflect did:key-only rule.
- Document non-key clone_url disk-path constraint in to_response.

* test(node): add SQL round-trip test for normalize_owner_key vs OWNER_KEY_CASE_SQL

Verifies every boundary value produces identical results from the
Rust function and the SQL CASE expression, preventing silent drift.

---------

Co-authored-by: Gravirei <[email protected]>
2026-07-03 08:12:27 +08:00
GravireiandGravirei 466a550915 fix(node): gate GET /ipfs/{cid} on reachable allowed-set, not deny-set (#126) (#133)
* fix(node): gate GET /ipfs/{cid} on reachable allowed-set, not deny-set (#126)

The IPFS visibility gate used withheld_blob_oids (a deny-set enumerating
only reachable blobs), so a dangling/unreachable blob was absent from the
set and served in cleartext to anonymous callers. Flip to an allowed-set
(allowed_blob_set_for_caller) that enumerates reachable blobs the caller
may read: a dangling blob has no path, is never in the set, and 404s.

* perf(ipfs): check object existence before allowed-blob walk

Move store::read_object before the allowed_blob_set_for_caller
spawn_blocking call so random-CID spray against repos with
path-scoped rules cannot trigger full-history git walks on
repos that don't carry the object.

* refactor(ipfs): improve formatting and readability in get_by_cid function

✓ P3 blocker fixed: cargo fmt applied — the format gate will pass.

  ✓ P3 cleanup resolved: withheld_blob_oids is still used by replication code in repos.rs, so it stays.

  • P2 follow-up: Tree/commit disclosure tracked in #135 — out of scope here.

* refactor(ipfs): streamline object retrieval by separating type and content reading

* docs(ipfs): update get_by_cid comment to reflect split object retrieval

* Run cargo fmt on store.rs

---------

Co-authored-by: Gravirei <[email protected]>
2026-07-02 08:11:38 +08:00
Yang Yuandparanoa233 09a0cb23b9 fix(remote): include HTTP error response body (#137)
* fix(remote): include HTTP error response body

* fix(remote): sanitize HTTP error response bodies

---------

Co-authored-by: paranoa233 <[email protected]>
2026-07-01 21:59:06 +08:00
Kevin Codex cd4680ebeb chore(aws): codify iCaptcha enforce in the EC2 node Terraform (#139)
The AWS EC2 node (manila) was upgraded to v0.4.0 and enabled iCaptcha
enforce by hand-editing its running /opt/gitlawb/compose.yaml — which would
be lost on instance replacement or a user-data re-run. Codify it so rebuilds
stay enforced:

- variables.tf: add icaptcha_mode (default "enforce"), icaptcha_pubkey
  (default the pinned live icaptcha.gitlawb.com Ed25519 key), and
  icaptcha_required_level (default 3).
- main.tf: pass the three vars into the compose templatefile render.
- compose.yaml.tftpl: set ICAPTCHA_MODE / ICAPTCHA_PUBKEY /
  ICAPTCHA_REQUIRED_LEVEL on the node service environment.

Operators can override per-deployment via terraform.tfvars.
2026-07-01 09:55:16 +08:00
Kevin Codex 8d96cab4f7 chore(node): seed Gitlawb nodes in bootstrap-peers; ignore node1 fly config (#132)
- bootstrap-peers.json: add node2, node3, and manila (manila.gitlawb.com)
  to the canonical seed list alongside node.gitlawb.com, and normalize the
  Gitlawb operator label to "Gitlawb". DIDs left null — bootstrap merges
  peers on http_url and the parser treats did as optional.
- .gitignore: ignore infra/fly/node1.fly.toml. node1's production Fly
  config is pulled from Fly (fly config save), not tracked in the repo.
2026-06-30 21:59:56 +08:00
github-actions[bot] 0190fbbbf6 chore(main): release 0.4.0 (#131)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
v0.4.0
2026-06-30 21:09:16 +08:00
beardthelion 86341d195d test(node): guard that every repo-scoped handler is visibility-gated (#122)
* test(node): guard that every repo-scoped handler is visibility-gated

Mirrors the existing every_in_scope_mutation_has_its_gate source-string CI test on
the egress side: scrapes every repo-scoped handler (any pub/pub(crate) async fn
taking Path<(String,String..)>) and asserts it carries an authz marker, either a
read gate (authorize_repo_read / visibility_check) or a write/owner/self gate
(require_repo_owner / require_owner / did_matches / &auth.0), or is listed in
KNOWN_UNGATED with its tracking issue. Verb-agnostic on purpose: a repo-scoped read
named outside list_/get_ (git_info_refs, replicate_encrypted_blobs, withheld_paths)
is still checked, so a new ungated egress of any name fails CI instead of shipping
as the next leak.

A completeness scan over src/api/ asserts every module declaring a repo-scoped
handler is wired into the guard, so a whole new file cannot add an ungated handler
the scrape never looks at. KNOWN_UNGATED is the live gap list (certs/issues/labels/
bounties/stars per #120, plus events/webhooks/replicas/protected pending PR #113);
the staleness assert forces each entry out the moment its gate lands. A count floor
trips if the scrape silently breaks, and unit tests pin handler_names and
is_repo_scoped.

Verified by execution: removing an allowlist entry trips the missing-gate assert;
listing a gated handler trips the staleness assert; dropping a source file trips the
completeness scan; breaking the parser trips the count floor; and a non-list/get
handler (git_info_refs) is confirmed scraped and gate-checked.

* test(node): don't let a service-conditional gate satisfy the egress guard (#122)

The guard counted a handler as gated on bare substring presence of a gate
marker, so git_info_refs false-passed on visibility_check( even though that
check only runs under `if service == "git-upload-pack"` and leaves the
git-receive-pack advertisement ungated. Add gate_runs_unconditionally, which
ignores markers that sit only inside an `if service ==` block, and track
git_info_refs in KNOWN_UNGATED until #119 makes the gate unconditional. When
that lands, the staleness assert forces the entry's removal.

* fix(review): harden gate_runs_unconditionally against unclosed blocks (#122)

Code review of the egress-guard hardening:
- Clamp the span-advance index so an unclosed `if service ==` block can't
  slice past the body end and panic (latent: real source is balanced, but the
  scraper should not crash on a pathological future body). Keep span-to-EOF as
  the fail-safe direction.
- Document that only `if service ==` is detected, not `match service`, so a
  future match-arm gate isn't silently treated as full.
- Add unit cases: gate inside both service blocks (conditional), gate inside
  and outside (full), and the unclosed-block no-panic regression.
2026-06-30 20:48:03 +08:00
Kevin Codex 8b9ceec25e feat(node): iCaptcha-aware repo propagation gate with quarantine (#125)
Phase 2 of the iCaptcha work: extend the proof-of-intelligence gate from
direct writes to federated propagation, so a node that enforces iCaptcha
can defend against spam repos that originate on a node which does not.

The proof now travels with the repo and is re-verified at mirror time:

- Capture & persist: create_repo/fork_repo store the verified proof
  (raw token + claims) against the new repo. New repo_icaptcha_proofs table.

- Serve: GET /api/v1/repos/{owner}/{repo}/icaptcha-proof returns the stored
  token, gated on whole-repo "/" read like the other replication endpoints.

- Admit at mirror time: the sync worker fetches the origin's proof and
  icaptcha::admit_mirror re-verifies it offline — signature + level +
  owner-DID binding, but NOT expiry — and consumes the jti in the local
  ledger so one solved challenge admits at most one mirror per node.

- Quarantine: in enforce mode a mirror that fails the gate is kept on disk
  but hidden from clone/serve/push (authorize_repo_read + the git serve and
  receive-pack handlers) and from listings (dedup CTE + federated list), via
  a new repos.quarantined column. Shadow logs only; off admits normally.
  Fail-safe: off/shadow/no-key always admit.

Hardening from review:
- git-receive-pack also rejects quarantined mirrors (no writes while hidden).
- mirror admission keys "first-time" on DB-row absence, not on-disk presence,
  so a clone that crashed before the DB insert is still gated on retry.
- mirror jti is spent against a fixed forward retention window, not the
  proof's already-past exp, so the replay record isn't swept early.

Known limitation: an iCaptcha proof binds to the owner DID, not the repo, so
a malicious origin can get one spam repo per harvested public proof admitted
(per-node jti single-use bounds the amplification). Fully binding a proof to
a repo needs a repo-scoped iCaptcha challenge (protocol change, future work).
The operator release HTTP endpoint is deferred (no admin-auth surface yet);
DB methods are in place.

Tested: verify_token expiry-skip + signature/level/DID enforcement on the
propagation path; proof round-trip; quarantine set/release, preservation
across re-sync, exclusion from listings; mirror jti retention survives a
sweep at the proof's exp. Full workspace green against Postgres.
2026-06-30 20:46:54 +08:00
Kevin Codex 12bfb1b3c8 fix(release): use simple release-type + generic Cargo.toml updaters (#130)
release-type: rust at path . is incompatible with this virtual
workspace root. release-please's Rust strategy unconditionally appends a
CargoToml update for the root Cargo.toml, and that updater throws
"is not a package manifest (might be a cargo workspace)" whenever the
manifest has no [package] section — so every run died on the final step.

Switch to release-type: simple (touches only a version file + CHANGELOG,
never a manifest) and bump each crate version through the generic updater
via extra-files plus an inline x-release-please-version annotation.

Preserves the single vX.Y.Z tag, all action outputs, and the entire
downstream release.yml unchanged.
2026-06-30 20:01:42 +08:00
Kevin Codex 788a8686c1 fix(release): give crates explicit versions for release-please (#129)
release-please's Rust workspace strategy versions each member crate in its own
Cargo.toml and cannot rewrite an inherited `version.workspace = true`, so it
failed with 'value at path package.version is not tagged'. Give each of the five
crates an explicit version = "0.3.9" (kept in lockstep by release-please) and
drop the now-unused [workspace.package].version. Inter-crate deps are path-only,
so no version requirements need syncing.
2026-06-30 18:24:38 +08:00
beardthelion 174f25a206 fix(node): gate GET /ipfs/{cid} on per-caller path-scoped visibility (#110) (#128)
* fix(node): gate GET /ipfs/{cid} on per-caller path-scoped visibility (#110)

get_by_cid served any git object by raw SHA-256 with no auth and no
visibility check. For a public repo with a path-scoped deny rule, an
unauthenticated caller could recover a withheld blob's oid from the
served trees, derive its CID, and fetch the cleartext here, bypassing
the pack-path withholding.

Bind optional caller identity (optional_signature on a dedicated
/ipfs/{cid} sub-router; the pins route stays unsigned, tracked by #121)
and gate each iterated repo row against its OWN rules: visibility_check
at "/" denies the repo, and withheld_blob_oids skips a blob withheld
from the caller when the row carries path-scoped rules. The row is gated
directly rather than via authorize_repo_read, whose fuzzy get_repo
re-resolve could authorize a different physical row than the one read.
The per-repo withheld set is memoized on repo.id within the request.

Adds get_by_cid to the authz_guard drift table (visibility_check marker).

Scope: this closes the direct unauthenticated scan. A stale-public
mirror row still serves withheld content on both this path and the pack
path; that pre-existing leak is tracked separately in #124.

* fix(review): batch visibility-rule lookup and fail-closed walk panic in get_by_cid

Code-review follow-ups for #110:
- Replace the per-repo list_visibility_rules call with a single
  list_visibility_rules_for_repos batch query (the existing #97 pattern),
  removing the N+1 on an unauthenticated scan and the "one repo's DB error
  500s the whole request" semantics.
- Fail closed on a spawn_blocking JoinError (walk task panic) by skipping the
  repo, matching the walk-error arm, instead of 500-ing the whole scan.
- Add a regression test for the repo-level "/" deny branch (private repo,
  anon denied / owner served) and note the access gate in the module doc.

* test(node): cover the fail-closed walk-error arm in get_by_cid

Induce a withheld_blob_oids walk error (a ref pointing at a non-tree-ish
blob, same technique as visibility_pack::fails_closed_when_a_ref_cannot_be_traversed)
and assert the handler skips the whole repo: the withheld blob 404s with no
leak, and the public blob in the same repo also 404s (proving fail-closed-skip
rather than serve-on-error). Verified load-bearing: mutating the Ok(Err) arm to
treat the error as no-withholding makes it RED (secret served 200).
2026-06-30 18:16:33 +08:00
Kevin Codex 3f08d9936b Release automation (#127)
* ci(release): add Windows target and cross-platform packaging

Add x86_64-pc-windows-msvc to the release matrix. Windows ships a .zip
(7z) while unix keeps .tar.gz; a portable sha256 helper handles
sha256sum/shasum/certutil. Smoke test and packaging now resolve the
.exe suffix on Windows. Release attach uses globs to cover both formats.

* feat(release): vendor npm wrapper and auto-publish to npm

Vendor the @gitlawb/gl npm wrapper (optionalDependencies platform-package
pattern) into npm/, replacing the standalone gl-npm repo. A new npm-publish
job downloads the four unix release tarballs from the GitHub release, lays in
the binaries, stamps every package.json with the release version, and runs
npm publish --provenance. Guarded on NPM_TOKEN so it no-ops until the secret
is configured.

* feat(release): auto-bump Homebrew tap on release

Add a homebrew-bump job that regenerates Formula/gl.rb in Gitlawb/homebrew-tap
from the release tarballs' sha256 checksums (macOS+Linux, arm64+x64) and pushes
the bump. Guarded on HOMEBREW_TAP_PAT so it no-ops until the secret and tap repo
exist. Enables 'brew install gitlawb/tap/gl'.

* feat(release): Windows installer and web auto-sync on release

Add install.ps1 (Windows PowerShell installer, source of truth in node).
Add a web-sync job that opens a PR to Gitlawb/web on each release syncing
install.sh + install.ps1 + public/version.json, plus idempotent drift fixes
(remove orphaned public/bin, fix stale skill.md releases link). Guarded on
WEB_SYNC_PAT. Vercel deploys on merge.

* docs(readme): document npm, Homebrew, and Windows install methods

* ci(release): build only CLI binaries on Windows

The gitlawb-node daemon targets Linux/macOS; building it on Windows risks
a compile failure that would block the whole release (npm/brew/web jobs all
depend on release-binaries). Windows now builds only gl + git-remote-gitlawb;
the smoke-test and package loops follow the per-target binary list.

* fix(release): reconcile release-please manifest with v0.3.9 and bump action to v5

The manifest still said 0.3.8 while the only tag is a manual v0.3.9 and
Cargo.toml is 0.3.9, so release-please had no valid baseline (no v0.3.8 tag)
and never cut a release. Set the manifest to 0.3.9 so release-please diffs
v0.3.9..main and opens a proper release PR for the next version. Also pin
release-please-action to v5.0.0 to match the openclaude pipeline.

* ci(release): set explicit minimal permissions on homebrew-bump and web-sync

CodeQL flagged both jobs for lacking a permissions block, leaving GITHUB_TOKEN
at broad default scopes. Both only need contents: read (cross-repo writes use
dedicated PATs).

* fix(release): harden installers and workflow per review

- install.ps1: fail closed if the .sha256 cannot be fetched; pure ASCII (no BOM)
- npm install.js: fail (exit 1 / throw) when the platform payload or a binary is
  missing instead of reporting a successful but broken install
- workflow: top-level permissions {} + persist-credentials: false on read-only
  checkouts
- docs: PowerShell-only Windows note; text language tag on npm/README fenced block

* fix(installer): require core CLI binaries on Windows

install.ps1 only failed when zero binaries were present, so a partial archive
missing gl.exe could still report success. Require gl.exe + git-remote-gitlawb.exe
(gitlawb-node.exe stays optional, matching the Windows build) and fail early if a
required binary is absent.

* ci(release): make Windows non-blocking and stop persisting cross-repo PATs

- release-binaries: continue-on-error for the Windows leg so a failure there
  no longer fails the job or blocks npm/Homebrew (matches the best-effort intent)
- homebrew-bump/web-sync: persist-credentials: false on the PAT-backed checkouts;
  supply the token only on the git push via an x-access-token URL

* fix(release): gate web-sync on binaries and detect native arch in installer

- web-sync now needs release-binaries so the site never advertises a release
  whose artifacts failed to build
- install.ps1 reads PROCESSOR_ARCHITEW6432 before PROCESSOR_ARCHITECTURE so a
  32-bit PowerShell host on x64 Windows is not wrongly rejected

* ci(release): make npm publish idempotent across reruns

Skip any @gitlawb package version already on the registry so rerunning the
job after a partial publish no longer errors on the first existing package
and leaves the wrapper/platform packages out of sync.

* fix(release): harden web-sync and tap commit per review

- web-sync: verify the Windows ZIP + .sha256 exist on the release before
  syncing install.ps1/version.json (Windows is best-effort, so a failed leg
  must not make the site advertise a missing installer)
- stage generated files before the no-op check so brand-new untracked files
  (formula, version.json) are committed instead of skipped
- gh pr create: only swallow the 'PR already exists' case; surface auth/API errors
- npm README: scope npm/yarn/pnpm/bun install to macOS/Linux/WSL, point native
  Windows at the PowerShell installer

* ci(release): guard npm-publish before doing any work

Check NPM_TOKEN in an up-front guard step (like homebrew-bump/web-sync) and
skip checkout, release downloads, extraction, version rewrites, and publish
when it is absent, instead of doing all that work and bailing in Publish.

* fix(release): robust web PR error handling and Windows installer hardening

- web-sync: capture gh pr list output so a real API/auth failure surfaces
  instead of being misread as 'PR already exists'
- install.ps1: enforce TLS 1.2 and use -UseBasicParsing so downloads work on
  stock Windows PowerShell 5.1; remove binaries no longer shipped on upgrade so
  an in-place update can't leave a mixed-version install; handle an empty user
  PATH without a leading separator
2026-06-30 16:56:58 +08:00
Kevin CodexandClaude Opus 4.8 adc20f9eff feat(node): iCaptcha proof-of-intelligence gate on create_repo + register (#108)
* feat(node): iCaptcha proof-of-intelligence gate on create_repo + register

Add an optional gate that requires callers to present an iCaptcha proof (an
Ed25519-signed token from icaptcha.gitlawb.com, earned by solving an escalating
challenge) on the two spam-prone endpoints. Proofs are verified OFFLINE with
ed25519-dalek against the published public key, and bound to the authenticated
agent DID so they cannot be shared.

Backward-compatible by default: ICAPTCHA_MODE is `off` unless set, so deploying
changes nothing. `shadow` verifies and logs would-be rejections but always
allows; `enforce` rejects requests lacking a valid, strong-enough proof. If the
public key cannot be loaded at startup the gate stays inert (fail safe) so an
iCaptcha outage can never break repo creation or registration.

- New src/icaptcha.rs: env-driven verifier (OnceLock), /v1/pubkey fetch at
  startup or ICAPTCHA_PUBKEY override, proof parse + signature + expiry + level
  + DID-binding checks; mode-aware decide() split out for testing.
- Wired into create_repo and register (X-ICaptcha-Proof header). git push
  (receive-pack) is intentionally NOT gated.
- 11 unit tests incl. verification of a real captured proof (JS-signed,
  Rust-verified), tamper/expiry/wrong-DID/level/missing-header rejections, and
  the off/shadow/enforce/fail-safe mode matrix.

Author: Kevin Codex <[email protected]>

* fix(node): address review on icaptcha gate (validate config, bound key fetch)

Addresses CodeRabbit and @jatmn review feedback on #108:

- parse_mode now returns Option and rejects unrecognized ICAPTCHA_MODE values;
  init() warns and falls back to off instead of silently disabling the gate on
  a typo like "enforced". [P2]
- ICAPTCHA_REQUIRED_LEVEL: warn on a non-empty unparseable value instead of
  silently lowering the threshold to the default. [P2]
- fetch_key uses a reqwest client with a 5s timeout so a hung /v1/pubkey can
  never block node startup; still fails inert on timeout/error. [P2]
- .env.example: reorder ICAPTCHA_* keys (MODE, PUBKEY, REQUIRED_LEVEL, URL) to
  satisfy dotenv-linter. [P3]
- add parse_mode unit test (rejects junk, accepts documented values).

cargo test -p gitlawb-node icaptcha: 12 passed. clippy + fmt clean.

Author: Kevin Codex <[email protected]>

* fix(node): consume iCaptcha proof jti so a proof can't be replayed

Addresses @jatmn P2 on #108: the verifier checked exp/level/sub but never
consumed the proof's jti, so one solved challenge was a reusable bearer proof
for that DID until expiry, covering every gated create_repo/register in the
window. Now, in enforce mode, each verified proof's jti is recorded once and
replays are rejected.

- db: migration v7 adds icaptcha_consumed_proofs(jti PK, expires_at); new
  consume_proof_jti (INSERT ON CONFLICT DO NOTHING -> bool) and
  sweep_expired_proofs. DB-backed so it holds across restarts and instances.
- icaptcha: ProofClaims carries jti; verify returns the claims; decide() stays
  pure and returns Allow | Reject | Consume{jti,exp}; check() is now async and
  spends the jti via the DB, rejecting replays (enforce only; shadow observes
  without consuming).
- handlers: create_repo/register await check(&state.db, ...).
- main: periodic sweep of expired proof rows alongside rate-limit cleanup.
- tests: 12 pass incl. decide() yields Consume with the proof's jti.

Author: Kevin Codex <[email protected]>

* fix(node): consume iCaptcha proof only after request passes validation

Addresses CodeRabbit (Major) on #108: check() spent the jti as soon as the proof
verified, but create_repo/register ran it before name/DID/existence checks, so a
rejected request (bad name, already-exists, wrong DID) permanently burned a
valid proof without the action succeeding.

Split verification from consumption:
- icaptcha: replace check() with verify_request() -> ProofGuard (verifies and
  rejects invalid/missing proofs early; off/shadow/inert yield a no-op guard)
  plus ProofGuard::consume(db) which spends the jti right before the write and
  rejects replays. Guard is #[must_use].
- create_repo/register: verify_request() up front, then consume() immediately
  before the first write, after cheap validation passes.

Replay protection unchanged (consume-before-write). 12 tests pass; clippy clean.

Author: Kevin Codex <[email protected]>

* fix(node): give iCaptcha rejections a dedicated error code

Addresses CodeRabbit on #108: iCaptcha failure paths returned
AppError::Unauthorized, which maps to the "not_an_agent" API code and conflates
a valid-but-unproven agent with a failed signature. Add a dedicated
AppError::IcaptchaProofRequired variant mapped to the "icaptcha_proof_required"
code (HTTP 401), and use it for both the missing/invalid-proof and replay paths
so clients can distinguish "solve a challenge" from "your signature is invalid".

12 tests pass; clippy + fmt clean.

Author: Kevin Codex <[email protected]>

* fix(node): gate fork_repo with iCaptcha and cover the proof ledger

Address remaining PR #108 review comments:

- fork_repo was the third repo-creation entrypoint and bypassed the
  iCaptcha gate. Verify the proof up front (fail fast on missing/invalid)
  and consume it only just before the git clone, so cheap rejections
  (bad name, conflict, withheld subtree) never burn a valid proof —
  matching create_repo's verify/consume split.

- Add #[sqlx::test] coverage for the single-use ledger that the gate's
  anti-replay value depends on: consume_proof_jti (first-use vs replay vs
  distinct jti) and sweep_expired_proofs (only expired rows removed,
  unexpired spent proofs still block replays).

- Return 403 Forbidden (was 401) for IcaptchaProofRequired: the caller is
  authenticated but forbidden without a valid proof. The distinct
  icaptcha_proof_required code is unchanged.

- Recommend pinning ICAPTCHA_PUBKEY in production in .env.example to
  avoid the startup fetch dependency.

The two shadow-mode review comments were already resolved by the earlier
verify/consume refactor (decide/verify are IO-free; check_proof_jti gone).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-30 10:58:08 +08:00
beardthelion 828dd279a2 fix(node): gate repo-listing and stats surfaces on visibility (#97, #99, #101, #104) (#111)
* fix(node): NFC-normalize visibility matcher to close NFC/NFD under-withholding (#101)

A deny rule authored in NFC byte-compared unequal to a path committed in NFD
(or vice versa), so blob_paths/withheld_blob_oids failed to classify the blob
and it shipped in cleartext on the replication/pack path. Normalize both the
glob prefix and the candidate path to NFC at the single matcher seam
(glob_matches + specificity); NFC not NFKC so compatibility forms are not
folded into distinct paths. Live serve is unaffected (git resolves byte-exact;
the gate only becomes more restrictive). Adds a matcher unit test and a
replication-path integration test with the variant byte inside the
rule-covered directory name.

* fix(node): fail closed on the full-scan pin path to stop dangling-blob leak (#99)

The withheld set is computed over reachable objects (rev-list --all + HEAD),
but the full-scan pin fallback (cat-file --batch-all-objects) includes dangling
objects the reachable walk never classified. Subtracting the reachable-only
withheld set therefore could not catch a private blob orphaned by a
force-push/amend, which pinned to IPFS/Pinata in cleartext.

Invert the default for blobs on the full-scan path: a candidate replicates only
if it is a non-blob (commits/trees are structural) or a reachable,
visibility-allowed blob; anything else (a dangling blob, or a withheld one) is
dropped. The delta path keeps the cheaper reachable-only subtraction since delta
candidates are always reachable, so no extra walk on the hot path.

Adds list_all_objects_with_type/all_blob_oids (typed enumeration),
replicable_blob_set + replicable_objects_fail_closed, and threads a full_scan
flag through resolve_candidates_for_push. Pin functions now take a pre-filtered
object list. Regression tests: dangling blob excluded end-to-end, fail-closed
unit semantics, and the all-blob universe includes dangling + excludes
non-blobs.

* fix(node): gate repo-listing surfaces on visibility to stop private-repo enumeration (#97)

list_repos, list_federated_repos, and the GraphQL repos query returned every
repo row with no visibility check, so an unauthenticated caller could enumerate
private repos' name, owner, description, and clone URL — and X-Total-Count
leaked the private-repo count even when rows were filtered.

Each surface now keeps a row only when visibility_check at "/" allows the
caller, the same decision the per-repo content endpoints make — not a bare
is_public filter, which would diverge for is_public=false-with-root-reader and
is_public=true-with-root-deny. The "/" decision depends on owner short/full-DID
matching and JSON reader-DID membership, so it is applied in Rust over an
over-fetched set rather than pushed into SQL; the paged path derives
X-Total-Count from the filtered set (dropping the SQL window count) and
paginates after filtering, so neither the page nor the count leaks. Adds a
batch root-rule query (list_visibility_rules_for_repos) to avoid N+1, and
converts list_all_repos_paged into list_all_repos_deduped (mirror dedup
preserved, pagination moved to Rust).

Tests: anonymous hides private repo + count, owner sees own private repo,
authorized root-reader sees an is_public=false repo (proving the visibility_check
path, not is_public), and federated listing hides private from anonymous.

* fix(review): share the listing visibility seam, extract fail-closed pin helper, add listing tests

Code-review follow-up on the VIS-subtree fixes (no behavior change to the three
shipped fixes; all confirmed sound by review):

- DRY the '/' listing gate into one shared visibility::listable_at_root used by
  list_repos, list_federated_repos, and the GraphQL repos query, so the three
  surfaces enforce one rule instead of three drifting copies
  (enforce-shared-rule-on-every-reader-path).
- Extract the full-scan fail-closed pin block into fail_closed_full_scan_objects,
  mirroring replication_withheld_set's degraded-path shape; drop the duplicated
  comment and the per-site no_rules sentinel (use &[]).
- Add the three listing tests the review flagged as missing: anonymous GraphQL
  repos hides a private repo, the paged X-Total-Count path excludes the private
  count (the KTD2 exploit shape), and an is_public=true repo under a root deny is
  not listed (proves the gate is visibility_check, not a bare is_public filter).

* fix(review): route the last inline root-visibility check through listable_at_root

Round-2 review follow-up. replication_withheld_set still made the announce
decision with an inline visibility_check(.., "/") == Allow — the fourth copy of
the listing gate the seam refactor was meant to collapse. Route it through
crate::visibility::listable_at_root (caller is always None, the anonymous
replication audience), so every reader of the '/' decision now shares one seam.
Also drop an em dash from the fail_closed_full_scan_objects doc comment.

* fix(node): gate the /api/v1/stats repos count through visibility to close the count oracle (#104)

The stats endpoint returned count_repos_deduped(): an unfiltered, unauthenticated
count of every logical repo including is_public=false and mode-A hide-existence
repos. A polling observer could detect that a hidden repo was created (N to N+1),
the exact existence signal mode-A suppresses everywhere else.

Count only the repos an anonymous caller could list: over-fetch the deduped set,
batch-load visibility rules, and keep rows that pass listable_at_root(.., None),
mirroring the listing seam in api/repos.rs. The caller is always None (meta_routes
carries no auth layer). Fail closed: any DB error collapses the count to 0, since
an under-count cannot leak existence.

count_repos_deduped is retained as the tested reference for the DEDUP_CTE dedup
count (its tests pin the CASE the live list paths share); it has no production
caller after this change and is allowed dead outside tests.

Tests: bare-private (no-rule branch), hide-existence mode-A with both repos
is_public=true (the Some(rule) branch), public-under-root-deny, list-parity,
sibling-fields-preserved, and empty-DB.

* test(review): add listing visibility regression guards (#97)

Three gaps the review flagged as uncovered:
- owner-filtered listing (?owner=) still hides a private repo and its count
  from anonymous (a distinct SQL bind branch from the unfiltered path).
- offset past the visible set returns an empty page while X-Total-Count stays
  the full visible total (guards against deriving the total from the cut page).
- a logical repo present as both a canonical row (with a root-deny rule) and a
  gossip mirror row stays hidden: pins that the DEDUP_CTE picks the canonical
  survivor so the batch rule lookup finds the deny. If dedup ever picked the
  mirror (slash-form id, no rule) the gate would fall back to is_public and leak.

* fix(review): address CodeRabbit feedback on #111

- visibility_pack NFC/NFD test: accept a SHA-1 or SHA-256 object id
  (40 | 64) instead of hardcoding 40, matching the fixture guard later in
  the file, so the test is not SHA-1-only.
- federated listing test: also assert the response `count` reflects only
  the visible repos, guarding against a regression to the pre-filter total.
- add an authenticated-caller GraphQL test: an owner sees their own private
  repo via `repos`, covering the resolver's caller-DID path that the
  anonymous-only test did not exercise.
- stats: count via the no-stars `list_all_repos_deduped()` (same DEDUP_CTE)
  instead of the stars-joined variant, since the count never needs stars.

* fix(review): match owner filter to did_matches for bare-owner mirror rows (#111)

Addresses jatmn's P2. The shared listing CTE filtered owners with
`owner_did = $1 OR owner_did LIKE '%:' || $1`, so a full `did:key:z...`
query did not match a mirror-only row stored with the bare owner `z...`.
#97 routed the no-limit owner-filtered path (used by `gl repo list`)
through this SQL predicate, regressing the full-DID-against-bare-mirror
direction that `crate::api::did_matches` handled in the old Rust path.

Normalize the bound owner to its did:key short form and compare it against
the row's owner key using the same CASE the dedup grouping already applies,
so full and short forms match symmetrically and a non-key DID still matches
only exactly. The no-arg `list_all_repos_deduped()` binds NULL and is
unaffected.

Regression test: a bare-owner mirror-only row is returned when queried by
both the full `did🔑` form and the bare short form. Confirmed RED
(test fails) before the fix.
2026-06-30 08:30:32 +08:00
beardthelion 6ae316cc88 fix(node): gate fork_repo on per-caller path-scoped visibility (#98) (#109)
* fix(node): gate fork_repo on per-caller path-scoped visibility (#98)

fork_repo authorized the caller only at the repo root "/" and then
git clone --mirror'd the full source, discarding the visibility rules.
A non-owner with root read but denied a path-scoped subtree could fork
the full mirror and obtain blobs the filtered git_upload_pack path
withholds.

Refuse the fork when the caller has any withheld glob (per-caller
withheld_globs non-empty), the same decision the read path makes;
owners and authorized subtree readers are unaffected. Fail closed with
a not-found response, before repo_store.acquire, so no withheld object
is materialized. Add unit coverage for the policy and a direct-mount
integration test pinning the gate wiring.

* fix(review): note fork-gate glob-grammar dependency, import withheld_globs

Address code-review findings on the #98 fork gate:
- Document that fork_withheld_blocks's prefix-probe equivalence with the
  serve path depends on validate_path_glob keeping "/" the only whole-repo
  scope, mirroring has_path_scoped_rule's caveat (latent-coupling guard).
- Import withheld_globs and call it unqualified, consistent with the
  sibling visibility_check import in the same module.
2026-06-28 09:01:13 +08:00
beardthelion 6809201daa fix(node): anchor the real old_sha and issue a per-ref certificate (#72)
* fix(node): anchor the real old_sha and issue a per-ref certificate (#26)

The push handler discarded the real old->new transition before anchoring
and only certified the first ref of a multi-ref push, so the permanent
Arweave record was a weaker, incomplete copy of facts the node had
already parsed.

- Thread the parsed old_sha through the clone moved into the anchor task
  instead of hardcoding a 64-zero placeholder. This also fixes the SHA
  width mismatch: object ids are 40-hex SHA-1, not 64-hex.
- Issue a signed RefCertificate for every ref the push advanced, each
  carrying that ref's real old/new, rather than one cert for the first
  ref (which, on an empty push, signed a timestamp string as new_sha).

Lock the anchor-body contract with a test asserting the upload carries
the real old_sha and new_sha, never a placeholder.

Scope is the integrity subset only. Carrying the signed certificate on
Turbo, capturing the pusher signature, chaining certificates, and
gl-side verification remain open under #26.

Refs #26

* fix(node): thread real old_sha through every ref-update fan-out path

PR #72 made the Arweave/DB anchor path per-ref with the real old_sha but
left three sibling fan-out paths in git_receive_pack flattening multi-ref
pushes and/or emitting a placeholder old_sha. Bring them in line:

- gossip publish_ref_update now forwards the real old_sha (was ""), matching
  the RefUpdateEvent contract that documents all-zeros for a created ref
- HTTP /sync/notify sends one signed request per ref with its real old_sha;
  the receiver is single-ref, so a multi-ref push no longer drops refs 2..N
- GraphQL RefUpdateBroadcast emits one broadcast per ref with the real
  old_sha instead of only the first ref + a zero placeholder

No receiver, struct, or wire-schema change; the all-zeros-for-created-ref
convention is preserved on the wire. Addresses jatmn's CHANGES_REQUESTED and
the two CodeRabbit findings.

* test(node): regression-guard the per-ref sync-notify fan-out

Extract the HTTP /sync/notify send into notify_peer_of_ref (one ref) and
notify_peer_of_refs (one peer, looping refs), collapsing the deeply-nested
inline block in git_receive_pack and giving the fan-out a unit-testable seam.
The ref_name is now also logged on every warn arm.

Add mockito regression tests: a two-ref push must emit one request per ref
each carrying its real old_sha (guards against re-flattening to .first() with
a zero placeholder, the bug PR #72 fixed), and a created ref must forward the
all-zeros hash verbatim. Behavior is unchanged; this only adds the seam, the
tests, and ref_name in the logs.

* style(node): match sibling test conventions in repos tests

Prefix the two new sync-notify tests with test_ and use the _mock binding
convention (matching arweave.rs/pinata.rs). No behavior change.

* style(node): fix rustfmt import order and dedupe the zero-sha literal

- Reorder the repos test-module imports so `use crate::...` precedes the
  external `gitlawb_core` import (unblocks cargo fmt --check / CI).
- Webhook `created` check reuses the existing ZERO_SHA constant instead of
  repeating the 40-hex literal.
- Arweave test fixtures use the canonical 40-hex zero old_sha, matching the
  SHA-1 width the per-ref anchoring contract now carries.

* perf(node): run peer notification after local broadcast and anchoring

The post-push background task awaited the HTTP peer-notify fan-out before
the GraphQL subscription broadcast and Arweave anchoring. With per-ref
notification a slow or unreachable peer could delay both local subscribers
and permanent anchoring by the sum of the peer round-trips. Move the
peer-notify loop to run last; peers already learned of the push via the
gossip publish earlier in the task, so this only deprioritizes the
best-effort HTTP fallback.

* style(node): correct notify-ordering comment and reuse ZERO_SHA in test

Review follow-ups on the two prior commits:
- The peer-notify ordering comment claimed peers 'already learned via the
  gossip publish', but that publish is gated on a present p2p handle; with
  no handle (the very case the HTTP fallback exists for) it does not run.
  State the actual rationale: peer-notify is the lowest-priority best-effort
  step, ordered last so it cannot delay local broadcast or anchoring.
- The all-zeros notify test still inlined the 40-hex literal; point it at the
  ZERO_SHA constant like the production path.
2026-06-27 23:16:23 +08:00
beardthelion 3e1e9045e4 fix(node): close under-withholding via full ref scope and full-history classification (#42) (#84)
* fix(node): close under-withholding via full ref scope and reachable-only pin set

blob_paths walked only refs/heads/* and refs/tags/* and skipped silently on a
failed git ls-tree, so a blob reachable only through another namespace, or a ref
that failed to traverse, could fall out of the withheld set and ship in cleartext.
Walk every ref and fail closed on traversal error.

The pin enumerators (ipfs_pin, pinata) used git cat-file --batch-all-objects,
which includes unreachable/dangling objects that have no path and cannot be
classified for withholding. Switch them to git rev-list --objects --all so the
pin set matches the reachable graph blob_paths evaluates.

* fix(node): include HEAD in the withheld-blob walk to close pin/serve asymmetry

blob_paths classified objects from for-each-ref only, but the pin set
(git rev-list --objects --all) and git upload-pack both also expose
objects reachable from HEAD. A detached HEAD (or any HEAD pointing
outside the ref-covered set) therefore put a blob into the pin/serve
path that the withholding walk never classified — a latent cleartext
leak of a withheld blob. Walk HEAD too (guarded for unborn HEAD on an
empty repo) so the withheld set is a superset of every exposure path.

Adds a regression test: a blob reachable only via a detached HEAD must
still be withheld (fails without this change).

* fix(node): classify blobs across full history, not just ref tips

blob_paths walked only ref-tip trees (git ls-tree -r <ref>), so a blob
that existed in an older commit and was deleted or rotated at the tip
was never classified, while the pin set (git rev-list --objects --all)
and upload-pack still expose it from history. That under-withheld the
blob into the IPFS/Pinata replication paths in cleartext.

Enumerate all reachable commits (git rev-list --all + HEAD for the
detached case) and walk each commit tree, deduping (oid, path) pairs.
ls-tree per commit is kept over rev-list --objects so every path a blob
lives at is still represented for the per-path visibility check. A ref
that resolves to a non-commit now fails closed explicitly, since
rev-list --all silently skips it where the old per-ref ls-tree errored.

Regression test: a secret blob deleted at the tip but reachable in
history is withheld.

* fix(node): share one history walk and fully peel ref types in withheld classifier

Addresses two review findings on the under-withholding classifier:

- withheld_blob_recipients walked blob_paths twice (once via
  withheld_blob_oids, once directly). Extract withheld_from_pairs so a
  single history walk feeds both the withheld set and the recipient map.

- The ref-type fail-closed guard used for-each-ref %(*objecttype), which
  peels only one tag level and so misclassified a nested annotated tag of
  a commit as a non-commit, refusing the whole walk (over-withhold). List
  refnames and peel each fully with <ref>^{} through one cat-file
  --batch-check; reap the child even if the stdin write fails. Fail closed
  on a short read or any non-commit/missing target.

Adds tests: annotated + nested-tag of a commit does not fail closed,
annotated tag of a blob still fails closed, and a blob at both an allowed
and a denied path is not withheld.

* fix(node): drain cat-file stdout concurrently to close the peel deadlock

The ref-type guard peeled every <ref>^{} through git cat-file --batch-check,
writing all queries to stdin before draining stdout. On a repo with enough
output the child's stdout pipe fills, it stops reading stdin, and write_all
blocks forever — hanging every serve/pin that classifies the repo. Batching
the queries does NOT bound this: cat-file echoes the full query on a
'<query> missing' line, so output scales with refname length, and a few
hundred long-named dangling refs already exceed the pipe buffer.

Run the stdin write on a separate thread so the main thread drains stdout
concurrently via wait_with_output, which reaps the child unconditionally
before any error is surfaced; the writer is joined after the drain so it
cannot deadlock, and a vanished stdin pipe becomes a broken-pipe write error.
One pass, no batch bound, deadlock-free regardless of ref count or refname
length.

Extracted as assert_all_refs_are_commits with corrected doc attribution.
Detect the missing-object line by its trailing status word. Adds a
timeout-bounded regression test with 500 long-named dangling refs (>64 KiB of
cat-file output) that fails rather than hangs if the deadlock returns.

* fix(node): parse ls-tree -z raw paths and fail closed on non-UTF-8 to stop under-withholding

Plain `git ls-tree -r` C-quotes any path with non-ASCII or special bytes
(café.txt becomes "secret/caf\303\251.txt"), so the stored path no longer
matched a visibility rule like /secret/** and the blob was served/pinned in
cleartext. Switch blob_paths to `git ls-tree -rz` and split the NUL-delimited
records, which emit paths raw; the TAB field separator survives -z so the
per-record parse is otherwise unchanged.

Parse strictly with str::from_utf8 and fail closed on invalid UTF-8 rather than
from_utf8_lossy: a lossy decode would rewrite an invalid byte in a denied
directory name to U+FFFD, and the mangled path would again miss its deny rule
(the same under-withholding class one layer down).

Tests: a non-ASCII path stays withheld (and the public blob still replicates),
and a non-UTF-8 path fails closed.
2026-06-27 23:15:38 +08:00
beardthelion 1960f6b28f ci(triage): label fork PRs and stop auto-closing PRs that only lack an issue link (#107)
* ci(triage): run PR triage on pull_request_target so fork PRs can be labeled

Fork PRs run the triage job with a read-only GITHUB_TOKEN under the
pull_request event regardless of the workflow's declared write
permissions, so addLabels/createComment fail with 403 'Resource not
accessible by integration' for every external contributor (e.g. #103).

Switch the trigger to pull_request_target, which runs in the base-repo
context with the declared write token. The job never checks out or runs
PR head code (it only reads context.payload and the pulls/files API and
manages labels), so it carries none of the untrusted-code-execution risk
that makes pull_request_target dangerous.

* ci(stale): drop needs-issue from auto-close trigger, keep it advisory

needs-issue flags PRs that link no issue, but linking one is a SHOULD in
CONTRIBUTING (required only for protocol-level changes), and many
legitimate small PRs (docs, obvious fixes) have none. Coupling it to the
14/21-day auto-close was overreach: a substantive, well-described PR
could be closed purely for missing an issue reference.

Keep needs-issue as an advisory label and guidance nudge; leave only
needs-description (an empty/thin body, a far stronger low-effort signal)
as the auto-close trigger.

* ci(stale): align stale message with description-only close condition

The auto-close trigger narrowed to needs-description, but the stale
message still told contributors a PR could close for a missing linked
issue. Drop that clause so the warning matches what actually closes a PR.

* style(ci): replace em dashes in triage/stale contributor messages

* docs(contributing): match stale close policy to description-only trigger

Auto-close is driven by needs-description only; a missing issue link stays
advisory. Align the contributor docs with the workflow so the OR wording no
longer implies a missing issue link can close a PR.
2026-06-27 23:14:41 +08:00