Commit Graph
45 Commits
Author SHA1 Message Date
beardthelion 9a46e8cbb7 fix(core): zeroize the derived X25519 secret (#65) (#66)
x25519_secret_from_seed derived the X25519 private scalar from the Ed25519
seed and returned it as a bare [u8; 32]. Because [u8; 32] is Copy with no
Drop (and sha2's digest output likewise has no zeroize), the scalar, the
SHA-512 digest, and the returned temporary were released without being
scrubbed, leaving secret-derived material in freed memory. This is the same
Copy-no-Drop class fixed for the raw seed in #41, on the derived secret.

Return Zeroizing<[u8; 32]>, build the scalar directly into a zeroizing
buffer so no bare secret local persists, and explicitly wipe the SHA-512
digest before it drops. Callers deref the result into crypto_box::SecretKey,
which already scrubs its own copy. No behavior change: the derived scalar and
all decryption output are byte-identical, covered by the existing
ed25519_to_x25519_keypair_agrees and seal_open_round_trip_for_recipients tests.

Stacked on #41 (#64).
2026-06-23 22:05:56 +08:00
beardthelion e4f5e87e0b fix(node): migrate node_seed pin caller to to_seed()
main grew a third seed_bytes() caller (repos.rs, from #40) after this
branch was cut. Route it through to_seed() too: node_seed is moved into
a long-lived tokio::spawn pin task, so the Zeroizing wrapper scrubs the
copy when that task ends. encrypt_and_pin takes &[u8; 32]; the wrapper
deref-coerces, so the call site is unchanged.
2026-06-22 12:14:40 -05:00
beardthelion 38e654a289 Merge remote-tracking branch 'origin/main' into fix/zeroize-seed-bytes 2026-06-22 11:59:42 -05:00
beardthelion 4f814459cc test(core): pin to_seed's Zeroizing return type, drop duplicate round-trip
The to_seed_is_sole_seed_accessor test was named for a compile-time
property its body couldn't assert, and its from_seed -> DID check
duplicated from_seed_round_trip. Replace it with a type-annotated
binding that fails to compile if to_seed ever returns a bare [u8; 32]
again, plus the determinism check, which is the only behavior it
uniquely covered.
2026-06-22 11:52:38 -05:00
beardthelion 7533b74f79 ci: harden PR pipeline (audit, MSRV, beta matrix, release + docker smoke) (#57)
* ci: harden PR pipeline (audit, MSRV, release build, beta matrix, docker smoke)

Restructure the single stable-only fmt+clippy+test job into separate jobs:
fmt+clippy (stable), a test matrix on stable+beta (beta non-blocking),
build --release, cargo audit, an MSRV check pinned to Rust 1.91, and a
Docker build smoke test mirroring the release workflow. Add concurrency
cancellation for superseded PR runs and per-job timeouts.

Declare rust-version = "1.91" in [workspace.package] and inherit it in
every member crate so the stated MSRV is a real, CI-enforced contract.

* ci: disable credential persistence on checkout steps

Set persist-credentials: false on all six actions/checkout steps. These
jobs run untrusted PR code via cargo and docker build, and checkout's
default leaves the GITHUB_TOKEN in the local git config where that code
could read it. None of the jobs push or otherwise need git credentials.

* fix(deps): bump rustls-webpki to 0.103.13

Clears three advisories flagged by the new cargo audit job: a reachable
panic in CRL parsing and two name-constraint bypasses (wildcard and URI).
Semver-compatible patch, lockfile only, no manifest or code change.

cargo also corrects a stale lockfile reference while rewriting Cargo.lock:
data-encoding-macro-internal 0.1.17 (a build-time macro crate) requires
syn 1.x, and its dependency entry is re-pinned from syn 2.0.117 to the
1.0.109 it actually resolves to. Unrelated to the bump, unavoidable when
cargo touches the lock, and the more-correct state.

* ci(audit): suppress no-fix advisories with justification and a re-check

The cargo audit job stays hard-fail. The remaining three advisories have
no available upstream fix, so they are suppressed in .cargo/audit.toml
(the location cargo-audit actually reads; a repo-root audit.toml is not
read), each with an inline rationale and removal condition:

- RUSTSEC-2026-0118/0119 (hickory-proto 0.25.2, via libp2p-dns 0.44):
  reachable, since the node wraps QUIC in the system DNS resolver. No fix
  until libp2p-dns ships hickory 0.26. Accepted, reachable risk.
- RUSTSEC-2023-0071 (rsa Marvin): in the lockfile via sqlx's unconditional
  sqlx-mysql, but not linked (sqlx is postgres-only), so no RSA oracle
  exists; no upstream fix regardless.

Two controls keep the suppression honest. The PR audit job echoes the
active ignore list to the job summary, so a green check is not read as
advisory-clean. A weekly scheduled job re-runs cargo audit without the
ignores for visibility and hard-fails if hickory-proto moves off 0.25.x
while the ignores remain, so the suppression cannot silently outlive the
upstream fix.

* ci(audit): reference tracking issue #76 in the hickory ignores

* ci(audit): treat hickory-proto removal as drift too

The scheduled drift guard only failed when hickory-proto was present and
off 0.25.x, so removing it from Cargo.lock entirely left the RUSTSEC-2026-0118
/0119 ignores in place with the guard still passing. Treat an absent version
as drift as well, so dead ignore entries are caught whether the dependency is
bumped past 0.25.x or removed.
2026-06-22 19:02:38 +08:00
beardthelion abdc775770 feat(node): blind recipient identities at rest and gate B1 by repo readability (#40)
* feat(node): blind recipient identities at rest and gate B1 by repo readability

The origin no longer stores recipient DIDs. Migration v5 replaces the
encrypted_blobs.recipients column with an opaque, node-keyed recipients_tag
used only to detect a recipient-set change for re-seal. B1 discovery and fetch
are now gated by the same repo-readability check the git read path uses, not by
per-recipient matching; decryption is gated by the envelope crypto, so a
non-recipient who can read the repo sees a blob's {oid, cid} but cannot open it.
encrypt_and_pin keys the tag from the node seed and returns {oid, cid}; the
Arweave manifest tuple drops the now-unused recipient vec.

A DB compromise no longer reveals the reader set; recovering it would require
brute-forcing candidate DID sets against the keyed tag with the node key.

* chore: remove redundant .gitignore entry (covered by local exclude)

* fix(node): gate /replicate by repo readability; harden reseal on DB error

Address review on the at-rest blinding change:
- The encrypted-blobs/replicate listing returned {oid, cid} with no visibility
  check, so a non-readable repo's blob index was reachable by an unauthenticated
  caller who guessed {owner}/{repo}. Gate it by the same repo-readability check
  discovery and fetch use. For the intended case (a public repo with withheld
  subtrees) the public root keeps this open to peers; only fully non-readable
  repos are withheld, which is the desired behavior.
- encrypt_and_pin treated a recipients_tag DB read error as a cache miss and
  resealed, causing avoidable IPFS writes during a partial outage; skip and retry
  on the next push instead.
- Correct the get_encrypted_blob doc comment to describe repo-readability access.
2026-06-22 19:01:19 +08:00
beardthelion d45318281c fix(core): route seed access through the zeroizing wrapper (#41)
Remove Keypair::seed_bytes(), which returned a bare [u8; 32] copy of the
Ed25519 private seed. Because [u8; 32] is Copy with no Drop, each caller's
copy was released without being scrubbed, leaving secret key material in
freed memory. The zeroizing accessor to_seed() -> Zeroizing<[u8; 32]>
already existed beside it; this makes it the sole seed accessor and migrates
the two in-crate callers (envelope open path and a crypto test) to it via
deref coercion. No behavior change to envelope encryption: the derived
X25519 secret is byte-identical, covered by the existing round-trip tests.

Adds an identity test pinning to_seed() as the only seed accessor.
2026-06-19 22:01:08 -05:00
beardthelion e37ea7fec6 fix(node): enforce path-scoped visibility on the REST read API (#52)
* fix(node): enforce path-scoped visibility on the REST read API

The repo-scoped REST read endpoints (blob, tree, commits, refs, changelog,
pulls, pr diff, repo metadata) read straight from disk after a bare repo
lookup, with no visibility check, so private repositories and private
subtrees of public repositories were served to unauthenticated callers,
bypassing the withholding the git and replication paths enforce.

Add a shared authorize_repo_read gate (resolve repo, load rules,
visibility_check, 404 on deny) and call it from every repo-scoped read
handler. get_blob gates at the requested file path; get_pr_diff withholds
the whole diff when it touches a path the caller cannot read. Attach
optional_signature to the read routes so authorized readers authenticate.
Listings stay gated at the repo root, consistent with mode B exposing tree
and commit SHAs over git.

Closes #51

* fix(node): gate PR reviews/comments and fork on repo read visibility

The REST visibility gate added in this PR missed three read surfaces:
list_reviews and list_comments served a private repo's PR reviews and
comments to unauthenticated callers, and fork_repo cloned a full mirror of
the source without checking the caller could read it. Route all three through
authorize_repo_read at the repo root, matching the git-protocol gate.

Also make branch_diff_names fail closed: it ignored git's exit status and
returned an empty path list on error, which would let get_pr_diff serve a diff
it should have withheld. Fix changelog.rs import order to satisfy cargo fmt.

* fix(node): NUL-delimit branch_diff_names to keep path-gate enforcement exact

git diff --name-only quotes and escapes paths containing newlines, so they
no longer match the visibility globs in get_pr_diff. On a public repo where /
is allowed but a subtree is denied, a touched restricted path could be missed
by the deny check and the full PR diff disclosed. Use -z and split on NUL to
preserve exact path bytes.
2026-06-19 22:53:18 +08:00
beardthelion 197c4e1cf1 Add contributor templates, issue forms, and PR triage automation (#58)
* docs: add pull request template

* docs: add structured issue forms and template chooser config

* chore: add CODEOWNERS for CI, release, and security paths

* ci: add quality-signal PR triage workflow

* ci: add label-gated auto-stale workflow for ignored PRs

* docs: document PR quality bar and triage/stale policy in CONTRIBUTING

* ci: recognize inline #[cfg(test)] tests and fix triage comment grammar
2026-06-19 22:52:32 +08:00
a5d2264cc0 ci(release): smoke-test packaged binaries' --version before attaching (#59)
The release-binaries job packaged gl, git-remote-gitlawb, and gitlawb-node
into the release tarball but never ran them, so a broken artifact could ship.
This is the follow-up promised in #30 and the gap noted in OSS-READINESS-AUDIT
("release binaries are not smoke-tested after packaging"), now unblocked by
git-remote-gitlawb gaining --version.

Run each binary's --version after build and assert the released version string
is present. Skipped for aarch64-unknown-linux-musl, which is cross-compiled and
can't execute on the x86_64 runner.

Co-authored-by: FrozenRaspberry <[email protected]>
Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-06-19 22:50:35 +08:00
beardthelionandCommandCodeBot 5ff7af84fa feat: encrypted replication for private subtrees (B1/B2/B3) for #18 (#36)
* feat(core): ed25519 to x25519 key conversion for envelope crypto

* feat(core): seal/open per-blob encryption envelopes

* feat(node): resolve per-blob encryption recipients (least-privilege)

* feat(node): encrypted_blobs table and recipient-scoped queries

* feat(node): encrypt-then-pin withheld blobs at push (IPFS)

At the push chokepoint, after pinning withheld objects, resolve each
withheld blob's recipient DIDs and seal it to their Ed25519 keys with
seal_blob, pinning the ciphertext to IPFS and recording it in
encrypted_blobs. Best-effort per blob: failures are logged and skipped,
never pinned in plaintext. Pinata replication is unchanged; B1
encrypts to IPFS only.

Adds ed25519-dalek as a direct dependency of gitlawb-node (it was only
declared in the workspace Cargo.toml).

* feat(node): authenticated discovery and fetch for encrypted blobs

* feat(gl): transparent recovery of authorized encrypted blobs on clone

* style: cargo fmt

* fix(node): re-seal encrypted blob when recipient set changes

Skip re-pinning only when an existing envelope already covers exactly the
current recipients. A reader added to a rule after the first pin now gets
a re-seal on the next push instead of being permanently locked out.
Reader removal stays non-retroactive (the old envelope is already public).

* feat(node): unscoped encrypted-blob listing for replication

* feat(node): encrypted-blobs/replicate endpoint for peer mirrors

* feat(node): route encrypted-blobs/replicate under git_read_routes

* feat(node): peer mirrors replicate encrypted withheld blobs (Option B2)

* feat(node): anchor_encrypted_manifest for Option B3 Arweave manifests

* feat(node): encrypt_and_pin returns the per-push sealed delta

* feat(node): anchor encrypted-blob manifest on push (Option B3)

* feat(gl): Arweave/IPFS gateway recovery for encrypted blobs (Option B3)

* feat(gl): transparent Arweave fallback recovery on clone (Option B3)

* style: cargo fmt

* fix(gl): bound Arweave recovery gateway requests with a 30s timeout

* test(gl): read-path recovery tests for Arweave fallback; quiet promisor present-check

Add two hermetic integration tests for recover_from_arweave that drive the
full read path over mocked Arweave GraphQL + IPFS gateways: discover the
manifest, fetch it, fetch the envelope, decrypt, and install the withheld
blob. One covers an authorized recipient (blob installed), the other a
non-recipient (nothing recovered). Both simulate origin death by removing
the promisor remote and enable uploadpack.allowFilter so the blob is truly
withheld over file://.

Also harden the local presence check in recover_from_arweave with
GIT_NO_LAZY_FETCH=1 and .output() so the expected 'missing object' case does
not trigger a wasted promisor fetch or leak git stderr to the user.

* fix: surface corrupt recipients JSON and silent recovery I/O failures

db: parse_recipients now propagates a descriptive error instead of
defaulting corrupt recipients JSON to an empty list, which would have
denied authorized readers and handed peers incomplete metadata.

gl: clone recovery now warns when the sparse-checkout file cannot be read
or written, or when the post-recovery checkout fails, instead of silently
discarding those errors and claiming files were recovered.

* feat(core): blind recipient identities in withheld-blob envelopes

Envelope v2 drops the cleartext recipient public key (kid) from each
wrapped-key header entry, so a party holding the public IPFS/Arweave copy
can no longer enumerate who is authorized to decrypt a withheld blob. The
version is bumped to 2 and v1 envelopes are rejected. open_blob now selects
the reader's entry by trial decryption (the AEAD tag authenticates exactly
one entry) instead of matching on the public key.

Recipient count is still visible; identity is not. Scope is envelope-only,
the node DB recipients column and peer replication metadata are unchanged.

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

* fix(core): reject malformed envelope nonces instead of panicking

open_blob fed attacker-controlled envelopes to GenericArray::from_slice and
XNonce::from_slice, which panic on a wrong-length input. Validate both the
per-recipient box nonce and the body nonce to 24 bytes before use: skip a
recipient entry whose nonce is malformed, and return an error for a malformed
body nonce, so the public recovery path surfaces an error rather than panicking.

* feat(node): blind recipient identities on the B2 replication surface

The encrypted-blobs/replicate endpoint shipped every blob's full recipient
DID list to mirrors, which persisted it, so any mirroring peer learned the
reader set. The v2 envelope blinding already removed recipient public keys
from the pinned envelopes, so the comment justifying this (DIDs are already
public) was no longer true.

/replicate now returns {oid, cid} only. Mirrors detect a re-seal by the CID
changing (the OID is stable across re-seals) instead of comparing recipient
sets, and store no recipient identities. Origin-side authz and the at-rest
recipients column are unchanged; this blinds only the peer-facing surface.

* feat(node): stop anchoring recipient identities to Arweave

The B3 encrypted-blob manifest anchored {oid, cid, recipients} to Arweave, a
permanent public record of every blob's reader set. The v2 envelope blinding
already removed recipient keys from the pinned envelopes, so the comment
justifying this (recipient DIDs are already public) was no longer true. The
manifest now anchors {oid, cid} only. The gl reader already ignores the
recipients field (it recovers by trial decryption), so no reader changes.

---------

Co-authored-by: CommandCodeBot <[email protected]>
2026-06-19 22:48:33 +08:00
beardthelionandKevin Codex 8680d0f9d6 feat(node): replication enforcement (Phase 2) for #18 (#34)
* docs(node): Phase 3 subtree-withholding plan with Task 0 spike findings

* feat(node): resolve withheld blob OIDs for path-scoped visibility

* feat(node): filtered upload-pack serve that omits withheld blobs

* feat(node): serve filtered pack when caller has withheld subtree blobs

* test(node): end-to-end assert served pack omits withheld blob

* docs(node): note why info/refs is not gated on subtree visibility

* fix(node): frame filtered serve as protocol v0 and prove it with a real clone

upload_pack_excluding emitted a v2 packfile section, but info_refs
advertises v0, so real clients negotiated v0 and rejected the response
with 'expected ACK/NAK, got packfile'. Frame the v0 stateless-rpc shape
instead (NAK, then the pack via side-band-64k when offered).

Add an end-to-end test that stands up info_refs + upload_pack_excluding
and runs a real git partial clone, asserting the withheld blob's bytes
never reach the client while its tree entry and SHA stay visible. A stock
full clone cannot consume the pack (it is not closed under reachability,
so fetch fails the connectivity check); a partial clone is required.

* docs(node): correct Phase 3 caveat (full clone refused at fetch, not checkout)

* test(node): prove incremental fetch still withholds; document negotiation choice

Add a real-git test that partial-clones, pushes a new commit server-side,
then fetches: the new object arrives and the withheld blob stays absent.
This pins down that ignoring have/want negotiation (always sending a
self-contained pack of all refs minus withheld, with NAK) is correct for
both clone and fetch; the only cost is a fetch re-sends the full object
set. Refactor the real-git tests onto a shared server harness and document
the negotiation decision in code and in the plan's follow-ups.

* refactor(node): address CodeRabbit review on PR #28

Move the two blocking git shell-outs in the filtered upload-pack path off
the async worker thread, matching the tokio::process / spawn_blocking usage
already in this file: build_filtered_pack (rev-list + pack-objects) and
withheld_blob_oids (per-ref ls-tree) now run inside spawn_blocking so a large
repo cannot stall the tokio runtime. Behavior is unchanged.

Also fix the Task 0 findings block in the Phase 3 plan: it still recorded v2
packfile framing, which is the exact path that failed against a real client
and was corrected to v0. The block now documents the shipped v0 contract.
Drop a stray trailing code fence flagged by markdownlint (MD040).

The speculative ls-tree timeout and the public/no-rules fast-path from the
review are intentionally left out: the timeout guards against adversarial
repos we do not yet host, and the fast-path is a micro-optimization not worth
the extra branch right now.

* chore(node): drop planning doc from PR per maintainer request

kevincodex1 asked to keep the superpowers planning docs out of the repo. The
Phase 3 plan was scaffolding for this change, not something the project needs
to carry. Removing it leaves only the code and tests in the PR.

* chore: gitignore local planning docs (docs/superpowers/)

* feat(node): replicable_objects filter for replication enforcement

* test(node): pin anonymous-caller contract of withheld_blob_oids

* feat(node): IPFS pinning skips withheld blob OIDs

* feat(node): Pinata pinning skips withheld blob OIDs

* feat(node): enforce visibility on push replication (IPFS/Pinata/gossip/Arweave)

* test(node): announce gate matches anonymous repo readability

* fix(node): run withheld_blob_oids off the async worker on push

The receive-pack replication chokepoint called withheld_blob_oids
directly on the tokio worker, where its blocking git ls-tree walk can
stall the runtime for repos with many refs. Wrap it in spawn_blocking
to match the upload-pack serve path.

---------

Co-authored-by: Kevin Codex <[email protected]>
2026-06-18 22:51:36 +08:00
Kevin CodexandOpenClaude c03c9af8fa feat(sync): auto-register as replica with origin after successful mirror (#56)
The sync worker mirrored repos from peers but never told the origin, so
replica_count (repo_replicas table, GET /api/v1/repos/:owner/:repo/replicas)
stayed at 0 network-wide — the dashboard showed 0 replication even for
repos that were actually mirrored.

After each successful clone/fetch the worker now sends a signed
PUT /api/v1/repos/{owner}/{repo}/replicas to the origin with our
public_url, using the same HTTP-Signature scheme as push notifies.
Best-effort by design: skipped when public_url is unset, failures are
logged without failing the sync, and the PUT is idempotent on the origin
(201 first, 200 after) so re-registering every sync is self-healing.

Nodes need this build before their mirrors are counted: a new node's
repos show replication once its peers upgrade.

Co-authored-by: OpenClaude <[email protected]>
2026-06-18 22:31:29 +08:00
Kevin CodexandOpenClaude 7a8b6c4974 Aws ecr image (#55)
* feat(infra): optional private ECR repo for the node image

create_ecr_repo = true provisions an ECR repository in-account and
switches the instance to pull from it: the instance role gets
AmazonEC2ContainerRegistryReadOnly and user-data configures
amazon-ecr-credential-helper, so first-boot pulls and SSM upgrades
authenticate via the role (no expiring login tokens). README documents
the local build-and-push flow.

Motivation: ghcr.io/gitlawb/node is not anonymously pullable (403),
which left first boot unable to start the stack.

Co-Authored-By: OpenClaude <[email protected]>

* feat(infra): HTTPS via Caddy + production tier (RDS, alarms, S3 state)

- domain_name: Caddy sidecar terminates TLS on 80/443 with auto-renewed
  Let's Encrypt certs (persisted on the data volume); public_url
  defaults to https://<domain>; SG opens 80/443 — matches the Fly nodes
- use_rds: managed Postgres (db.t4g.micro, encrypted, 7-day PITR,
  deletion protection) replaces the in-container postgres; DB security
  group only admits the node instance
- alert_email: SNS email alerts wired to CloudWatch alarms — external
  HTTPS /health via Route53 health check, EC2 status checks, CPU,
  RDS free storage
- user-data grows the filesystem when the EBS volume is enlarged
- terraform state moved to S3 (versioned, KMS-encrypted, public-blocked
  bucket) with lockfile locking (requires terraform >= 1.10)

Co-Authored-By: OpenClaude <[email protected]>

---------

Co-authored-by: OpenClaude <[email protected]>
2026-06-18 22:05:50 +08:00
Kevin CodexandOpenClaude 7a99d0f27c fix(node): make Tigris repo hydration resilient to corrupt archives & failed writes (#54)
Repo pushes 500'd whenever a repo's Tigris archive was unreadable (corrupt
or truncated) or the node was under disk pressure, because hydration was
treated as fatal and a failed extraction could clobber the good local copy.
Three hardening changes:

- tigris: extract archives atomically (unpack into a temp dir, swap into
  place only on success) so a corrupt/truncated archive can never clobber a
  good existing local copy.
- repo_store: acquire_fresh / acquire_write fall back to the valid local
  copy when a Tigris download fails, instead of hard-failing the operation;
  the post-write upload then re-syncs (self-heals) Tigris.
- repo_store: RepoWriteGuard::release(success) uploads to Tigris only when
  the write succeeded, so a half-applied / inconsistent repo is never
  propagated to Tigris or to other nodes.

Co-authored-by: OpenClaude <[email protected]>
2026-06-18 21:38:47 +08:00
Kevin CodexandClaude Fable 5 c7df6cad20 Fix/backport prod fixes (#39)
* fix: backport production fixes from monorepo

Three fixes that landed on the production node but never made it here:

- trigger_sync: 30s timeout with the body read inside it (was 5s
  covering headers only — large unpaginated repo lists from canonical
  nodes and transpacific peers aborted mid-body), plus distinct logging
  for non-2xx vs timeout
- announce: reject self-announcements by public URL or own DID;
  complements the existing boot-time prune_self_peers so self-loop
  rows never enter the peers table in the first place
- list_all_repos_paged: report MAX(updated_at) across each logical
  repo group instead of the canonical row's own value — gossip pushes
  touch only the mirror row, so canonical timestamps go stale

Monorepo refs: 8c2fe1b, c1dc33e, ef21c75.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(p2p): dual-stack QUIC listen + DNS multiaddr dialing for Fly 6PN

Backports the monorepo TCP-era p2p fixes (9f875b6, 267d371) adapted to
the QUIC transport:

- listen on /ip6/:: as well as /ip4/0.0.0.0 — Fly's 6PN inter-app
  network is IPv6-only (<app>.internal resolves to AAAA records), so
  without an IPv6 socket peers on the private network can't reach us
- wrap the QUIC transport in libp2p-dns so bootstrap multiaddrs can use
  /dns6/<app>.internal — dialing peers through the public anycast edge
  breaks the handshake when the proxy closes the connection mid-stream

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(sync): HTTP notify records received_ref_updates like the gossip path

A push propagated via the /api/v1/sync/notify HTTP fallback replicated
the repo but left no trace in received_ref_updates — only the libp2p
gossip path recorded it, so /api/v1/events/ref-updates went blind
whenever the mesh wasn't delivering. Insert the same record from the
notify handler, with optional provenance fields (pusher_did, old_sha,
timestamp, cert_id) that new senders already include.

Monorepo ref: 087eeac.

Co-Authored-By: Claude Fable 5 <[email protected]>

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-06-18 21:36:11 +08:00
Kevin CodexandClaude Fable 5 b61a1bd46f fix(api): blob endpoint returns 400/404 instead of 500 on bad paths (#37)
Unnormalized "../.." paths from crawler-exploded relative links made
`git show` fail and surfaced as 500s. Reject traversal segments with
400 up front, and map paths absent from the tree to 404 so only real
git failures count as server errors.

Co-authored-by: Claude Fable 5 <[email protected]>
2026-06-18 21:35:36 +08:00
beardthelion e365a57b51 feat(node): peer partial-mirrors for repos with private subtrees (#35)
* feat(node): withheld-paths endpoint reporting a caller's denied globs

* feat(gl): gl clone with promisor + sparse-exclude for private subtrees

* test(gl): gl clone repo-argument parsing

* fix(visibility): gate withheld-paths and handle nested/exact globs

Three fixes from the PR #33 review:

- withheld_paths now applies the whole-repo "/" read gate (returns
  repo-not-found when the caller cannot read the root), matching the git read
  endpoints. Without it the endpoint disclosed a private repo's existence and
  path layout to unauthorized callers. The withheld_globs doc already assumed
  this gate existed; now it does.

- A nested allow under a denied parent (e.g. "/secret/public/**" allowed,
  "/secret/**" denied) was over-withheld: the client sparse-excluded the whole
  parent and hid paths the caller may read. The endpoint now also returns a
  "reinclude" list (allowed globs strictly under a denied one) and gl clone
  re-includes them in the sparse spec after the excludes.

- Wildcard-free globs like "/docs/private" match both the exact path and a
  subtree (per glob_matches), but the client only emitted the subtree exclude.
  sparse_patterns now emits both "/docs/private" and "/docs/private/".

Verified the exclude-then-reinclude sparse ordering checks out cleanly with
real git, plus unit tests for reincluded_globs, the nested re-include, the
exact-path exclude, and sparse_patterns.

* fix(gl): reject multi-slash repo input and stop failing open on withheld-path errors

split_once('/') accepted owner/name/extra, smuggling a path segment into
the repo name that then flowed into the API path and clone URL; reject it.

fetch_withheld swallowed every network/auth/5xx/JSON error into an empty
result, dropping to a stock clone that the node refuses once blobs are
withheld. Now only 404/501 (endpoint unsupported) fall back to empty; the
rest propagate so the real cause surfaces.

* test(gl): guard three-level nested visibility in sparse clone

Add a regression test for deny /secret, allow /secret/public, deny
/secret/public/admin and clarify the sparse-checkout comment. git does
not re-traverse an explicitly excluded directory, so emitting all
excludes before re-includes keeps the deepest deny in force; the broader
parent re-include does not resurrect it.

* fix(gl): robust default-branch detection and strict withheld-paths schema

Read the default branch from the local origin/HEAD symref clone already
set, instead of parsing the localized, network-dependent output of
git remote show origin. Deserialize the withheld-paths body into a typed
struct so a missing or mistyped withheld/reinclude field is a hard error
rather than silently becoming an empty list, which would mask a server
regression behind a later clone failure.

* feat(node): classify mirror mode from origin withheld-paths

* feat(node): promisor-aware mirror clone for withheld repos

* feat(node): promisor-aware mirror fetch via origin remote

* feat(node): classify and mirror peers per withheld-paths

* style: cargo fmt

* fix(node): bound sync withheld lookup and rehydrate on promisor->plain

Give the sync worker's HTTP client a 30s timeout so a stalled peer
withheld-paths lookup cannot hang the worker loop. When a repo that was
mirrored as a promisor (mode B) becomes fully public, fetch_repo now
clears remote.origin.promisor and partialclonefilter and refetches, so
the once-withheld blobs are backfilled instead of the mirror staying
permanently partial.
2026-06-18 21:33:31 +08:00
beardthelion 4a59056a90 gl clone: clean partial clone of repos with private subtrees (#33)
* feat(node): withheld-paths endpoint reporting a caller's denied globs

* feat(gl): gl clone with promisor + sparse-exclude for private subtrees

* test(gl): gl clone repo-argument parsing

* fix(visibility): gate withheld-paths and handle nested/exact globs

Three fixes from the PR #33 review:

- withheld_paths now applies the whole-repo "/" read gate (returns
  repo-not-found when the caller cannot read the root), matching the git read
  endpoints. Without it the endpoint disclosed a private repo's existence and
  path layout to unauthorized callers. The withheld_globs doc already assumed
  this gate existed; now it does.

- A nested allow under a denied parent (e.g. "/secret/public/**" allowed,
  "/secret/**" denied) was over-withheld: the client sparse-excluded the whole
  parent and hid paths the caller may read. The endpoint now also returns a
  "reinclude" list (allowed globs strictly under a denied one) and gl clone
  re-includes them in the sparse spec after the excludes.

- Wildcard-free globs like "/docs/private" match both the exact path and a
  subtree (per glob_matches), but the client only emitted the subtree exclude.
  sparse_patterns now emits both "/docs/private" and "/docs/private/".

Verified the exclude-then-reinclude sparse ordering checks out cleanly with
real git, plus unit tests for reincluded_globs, the nested re-include, the
exact-path exclude, and sparse_patterns.

* fix(gl): reject multi-slash repo input and stop failing open on withheld-path errors

split_once('/') accepted owner/name/extra, smuggling a path segment into
the repo name that then flowed into the API path and clone URL; reject it.

fetch_withheld swallowed every network/auth/5xx/JSON error into an empty
result, dropping to a stock clone that the node refuses once blobs are
withheld. Now only 404/501 (endpoint unsupported) fall back to empty; the
rest propagate so the real cause surfaces.

* test(gl): guard three-level nested visibility in sparse clone

Add a regression test for deny /secret, allow /secret/public, deny
/secret/public/admin and clarify the sparse-checkout comment. git does
not re-traverse an explicitly excluded directory, so emitting all
excludes before re-includes keeps the deepest deny in force; the broader
parent re-include does not resurrect it.

* fix(gl): robust default-branch detection and strict withheld-paths schema

Read the default branch from the local origin/HEAD symref clone already
set, instead of parsing the localized, network-dependent output of
git remote show origin. Deserialize the withheld-paths body into a typed
struct so a missing or mistyped withheld/reinclude field is a hard error
rather than silently becoming an empty list, which would mask a server
regression behind a later clone failure.
2026-06-18 21:25:13 +08:00
3a401eb079 feat(git-remote-gitlawb): add --version and --help flags (#30)
git-remote-gitlawb was the only workspace binary without `--version`, so
release smoke tests could not validate the packaged helper directly. This
is called out in docs/MAINTAINER-ROADMAP.md ("Add --version support to
git-remote-gitlawb so release smoke tests can validate it directly") and
docs/OSS-READINESS-AUDIT.md.

- `--version`/`-V` prints `git-remote-gitlawb <version>`, matching the
  `<bin> <version>` format the clap-based `gl` and `gitlawb-node` emit so
  release tooling can treat all three binaries uniformly. The version
  comes from CARGO_PKG_VERSION.
- `--help`/`-h` prints concise usage (URL form, env vars, flags).
- Flags are only recognized as the first argument, so git's
  `<remote-name> <url>` remote-helper invocation path is untouched; a
  remote literally named "--version" is not mistaken for the flag.
- Informational flags are handled before tracing init so output stays
  clean for scripts.

Adds unit tests for argument classification (including the no-args and
misplaced-flag edge cases) and the version/help strings.

Co-authored-by: FrozenRaspberry <[email protected]>
Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-06-18 21:13:01 +08:00
beardthelion 61b383019f feat(node): subtree content withholding (Phase 3) for #18 (#28)
* docs(node): Phase 3 subtree-withholding plan with Task 0 spike findings

* feat(node): resolve withheld blob OIDs for path-scoped visibility

* feat(node): filtered upload-pack serve that omits withheld blobs

* feat(node): serve filtered pack when caller has withheld subtree blobs

* test(node): end-to-end assert served pack omits withheld blob

* docs(node): note why info/refs is not gated on subtree visibility

* fix(node): frame filtered serve as protocol v0 and prove it with a real clone

upload_pack_excluding emitted a v2 packfile section, but info_refs
advertises v0, so real clients negotiated v0 and rejected the response
with 'expected ACK/NAK, got packfile'. Frame the v0 stateless-rpc shape
instead (NAK, then the pack via side-band-64k when offered).

Add an end-to-end test that stands up info_refs + upload_pack_excluding
and runs a real git partial clone, asserting the withheld blob's bytes
never reach the client while its tree entry and SHA stay visible. A stock
full clone cannot consume the pack (it is not closed under reachability,
so fetch fails the connectivity check); a partial clone is required.

* docs(node): correct Phase 3 caveat (full clone refused at fetch, not checkout)

* test(node): prove incremental fetch still withholds; document negotiation choice

Add a real-git test that partial-clones, pushes a new commit server-side,
then fetches: the new object arrives and the withheld blob stays absent.
This pins down that ignoring have/want negotiation (always sending a
self-contained pack of all refs minus withheld, with NAK) is correct for
both clone and fetch; the only cost is a fetch re-sends the full object
set. Refactor the real-git tests onto a shared server harness and document
the negotiation decision in code and in the plan's follow-ups.

* refactor(node): address CodeRabbit review on PR #28

Move the two blocking git shell-outs in the filtered upload-pack path off
the async worker thread, matching the tokio::process / spawn_blocking usage
already in this file: build_filtered_pack (rev-list + pack-objects) and
withheld_blob_oids (per-ref ls-tree) now run inside spawn_blocking so a large
repo cannot stall the tokio runtime. Behavior is unchanged.

Also fix the Task 0 findings block in the Phase 3 plan: it still recorded v2
packfile framing, which is the exact path that failed against a real client
and was corrected to v0. The block now documents the shipped v0 contract.
Drop a stray trailing code fence flagged by markdownlint (MD040).

The speculative ls-tree timeout and the public/no-rules fast-path from the
review are intentionally left out: the timeout guards against adversarial
repos we do not yet host, and the fast-path is a micro-optimization not worth
the extra branch right now.

* chore(node): drop planning doc from PR per maintainer request

kevincodex1 asked to keep the superpowers planning docs out of the repo. The
Phase 3 plan was scaffolding for this change, not something the project needs
to carry. Removing it leaves only the code and tests in the PR.

* chore: gitignore local planning docs (docs/superpowers/)
2026-06-18 21:11:46 +08:00
Kevin CodexandClaude Fable 5 a2217bff27 fix(infra): drop fly idle_timeout 600 -> 120 (#38)
600s idle windows let hung clients pin Fly proxy connection slots up to
hard_limit, which amplified the 2026-06-12 production outage. 120s was
deployed there as the fix; carry it into the reference config.

Co-authored-by: Claude Fable 5 <[email protected]>
2026-06-12 21:55:57 +08:00
beardthelion 6abaf1d7ed feat: path-scoped repository visibility (Phase 1) for #18 (#25)
* feat: path-scoped repository visibility (Phase 1)

Implements per-path read visibility for repositories, addressing #18.
A repo's private status becomes a property of a path subtree rather
than a whole-repo boolean, enforced on the git read path via the node's
existing DID identity and RFC 9421 HTTP signatures.

Phase 1 scope (whole-repo enforcement, data model for the rest):
- visibility_rules data model (path_glob, mode, reader_dids) + DB access
- visibility_check pure decision function (owner-always-allow, most-
  specific rule wins, reader-DID allow-list, is_public fallback)
- enforcement on git_info_refs and git_upload_pack: unauthorized reads
  return 404 byte-identical to a missing repo (no existence leak)
- owner-only management API: PUT/DELETE/GET /api/v1/repos/{o}/{r}/visibility
- gl visibility set/remove/list CLI

mode A (hide) is whole-repo ("/") only by construction: hiding a
subtree's existence would rewrite ancestor tree hashes and diverge
history. Subtrees use mode B (content withheld, SHAs intact); B-subtree
clone filtering is deferred to a later phase and the CLI/API say so.

260 tests (69 node + 191 gl); fmt + clippy clean.

* fix: address review on path-scoped visibility

- migrations: move visibility_rules DDL out of the v1 migration into a
  new v3 (CodeRabbit, critical). Appending to v1 meant nodes that had
  already recorded v1 would skip the statements forever, so the table
  would be missing after upgrade. v3 matches the convention #23 set with
  v2 (agent_profiles) and the documented 'never append to v1' rule.
- api: validate path_glob on set (must start with '/', no trailing '/',
  only a trailing '/**' wildcard, '/' for whole-repo not '/**'). Empty
  or slash-less globs would otherwise silently misconfigure access.
  Added unit tests for the accepted/rejected forms.

281 -> 283 tests pass (88 node + 195 gl); fmt + clippy clean.
2026-06-05 22:42:26 +08:00
Kevin CodexandOpenClaude 9e6e832ec5 Infra reorg (#27)
* refactor: move fly.toml into infra/fly/

Deployment configs now live under infra/, organized per target.
Dockerfiles and docker-compose.yml intentionally stay at the repo
root (shared by CI, build scripts, and the macOS app).

Deploy with: fly deploy -c infra/fly/fly.toml

Co-Authored-By: OpenClaude <[email protected]>

* feat(infra): Terraform for single-EC2 AWS deployment

infra/aws/ provisions a production-leaning single-node deployment:
- t4g.small (AL2023 arm64) running the published ghcr.io/gitlawb/node
  image + postgres:16 via docker compose
- separate encrypted EBS data volume (prevent_destroy) with daily DLM
  snapshots; survives instance replacement
- postgres password via random_password -> SSM SecureString, fetched at
  boot by instance profile (never in user-data); optional secrets
  (operator key, Pinata JWT, S3 secret) follow the same path
- SSM Session Manager access (no SSH by default), IMDSv2 required,
  metrics port closed unless explicitly opened
- SSM command document for image upgrades (user-data runs once)

Verified: terraform fmt/init/validate clean; rendered compose passes
docker compose config; rendered user-data passes bash -n.

Co-Authored-By: OpenClaude <[email protected]>

* fix(infra): address CodeRabbit review feedback

- api_url output now honors the public_url override (local.public_url)
- disable xtrace around SSM secret fetch + .env write so secret values
  never reach the bootstrap log
- add retry/timeout flags to the docker compose download
- validate snapshot_retain_count (1-1000) at plan time
- add language tags to fenced tree blocks (MD040)

Co-Authored-By: OpenClaude <[email protected]>

* fix(infra): address PR #27 review feedback

- security group now derives its VPC from the selected subnet, so
  subnet_id overrides into non-default VPCs work
- s3_access_key_id moves to SSM SecureString (same path as the secret
  key) instead of being embedded in user-data and state
- ignore user_data drift on the instance: it only runs at first boot,
  so re-rendering caused pointless stop/starts; README documents the
  -replace workflow for config changes
- DLM snapshot targeting is stack-specific (Snapshot + Name tags), not
  any Snapshot=true volume in the account
- comment in fly.toml that it targets the shared test instance
- user-data compose up gains --remove-orphans (consistent with the
  upgrade SSM document)
- node service gets an HTTP /health healthcheck in compose

Co-Authored-By: OpenClaude <[email protected]>

* feat(infra): optional customer-managed KMS key for SSM secrets

Add ssm_kms_key_id (default null = AWS-managed aws/ssm key). When set,
all five SecureString parameters are encrypted with the CMK and the
instance role gains kms:Decrypt scoped to that key — without it the
boot-time secret fetch would fail.

Co-Authored-By: OpenClaude <[email protected]>

* fix(infra): scope CMK kms:Decrypt to this stack's SSM parameters

The grant on the customer-managed key was unconditioned, allowing the
instance role to decrypt any ciphertext under the same CMK. Restrict it
with kms:ViaService = ssm.<region>.amazonaws.com and
kms:EncryptionContext:PARAMETER_ARN limited to the stack's parameter
ARNs.

Co-Authored-By: OpenClaude <[email protected]>

---------

Co-authored-by: OpenClaude <[email protected]>
2026-06-05 22:38:53 +08:00
09a339745e feat: agent profiles (display name, bio, avatar, social links) (#23)
* feat: agent profiles — display name, bio, avatar, social links

Adds a profile system so agents and users can set metadata that the
frontend can render on profile pages.

CLI:
  gl profile set --name "Axiom" --bio "AI builder" --twitter AxiomBot
  gl profile show
  gl profile get <did>
  gl profile import profile.json
  gl profile export

API:
  PUT  /api/v1/profile              (authenticated — upsert own profile)
  GET  /api/v1/agents/{did}/profile (public — read any profile)

Database:
  Migration v2 adds `agent_profiles` table with display_name, bio,
  avatar_url, website, socials (JSON), profile_cid, timestamps.

Profile updates merge fields — setting --bio doesn't clear --name.
Bio capped at 280 chars, display_name at 50. Social links stored as
JSON (twitter, github, farcaster, telegram). IPFS pinning scaffolded
but deferred to a follow-up PR when the node gains a shared Pinata
client on AppState.

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

* fix: apply cargo fmt to pass CI formatting check

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix: suppress dead_code warnings for pin_to_ipfs field and set_profile_cid method

Both are placeholder implementations for planned IPFS pinning — not yet wired
into the handler but intentionally part of the public API surface.

Co-Authored-By: Axiom Bot <[email protected]>

* feat: wire up IPFS pinning for agent profiles via Pinata

When pin_to_ipfs: true is sent in the profile update request and the
node has GITLAWB_PINATA_JWT configured, the profile JSON is pinned to
IPFS via Pinata and the CID is stored in the database.

- Removed #[allow(dead_code)] from pin_to_ipfs field and set_profile_cid
- Profiles serialize to JSON and pin via existing pinata::pin_object
- Graceful fallback: if Pinata JWT is empty or pin fails, profile is
  saved normally without CID
- CID returned in response when available

---------

Co-authored-by: Axiom Bot <[email protected]>
Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-06-04 16:17:22 +08:00
Vasanth TandClaude Opus 4.8 2ce4da9cc5 feat(node): graceful shutdown + Prometheus metrics endpoint (#22)
* feat(db): versioned schema migrations with idempotent backfill

Replace the inline &str SQL array that re-runs on every node startup with a
versioned migration system. Each migration is recorded in a new
schema_migrations table and applied at most once per node, inside a single
transaction.

For nodes upgrading from a pre-migration-versioning build, the canonical
`repos` table is used as a signal to mark v1 as already applied without
re-running its ~140 statements — so existing operators see zero behavior
change on first restart after this commit.

Closes the gap called out in docs/OSS-READINESS-AUDIT.md:78.

- Adds Db::migration_status() returning applied (version, name, applied_at)
- Adds 6 unit tests validating the static migration catalogue
  (versions strictly increasing, names distinct, bodies non-empty, v1
  name locked to initial_schema)
- cargo fmt + clippy -D warnings + cargo test --workspace all clean

* fix(db): make legacy migration backfill safe and lock-guarded

Address two robustness gaps in the v1 migration runner on the live network:

- Remove the "repos table present => v1 complete" backfill short-circuit.
  It assumed every existing node already had the full current schema; a node
  that was behind would be marked v1-applied while still missing newer tables
  or columns. Every v1 statement is idempotent (CREATE TABLE/INDEX IF NOT
  EXISTS, ADD COLUMN IF NOT EXISTS), so legacy installs now simply run v1 once:
  existing objects are no-ops, missing ones get created.

- Guard the whole runner with a Postgres advisory lock so two instances
  against the same database (blue/green or rolling deploy) can't race to apply
  the same migration and trip the schema_migrations primary key. The lock is
  released explicitly, and automatically if the connection drops.

Also document that per-migration transactions preclude CREATE INDEX
CONCURRENTLY in future migrations.

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

* feat(node): graceful shutdown + Prometheus metrics endpoint

Two operational-safety changes that the live network needs:

1) Graceful shutdown. A process-wide tokio::sync::watch::Sender<bool>
   in AppState is flipped on SIGINT (all platforms) or SIGTERM (Unix).
   Every long-lived loop now selects on the shutdown arm:
     * axum server (with_graceful_shutdown — drains in-flight)
     * libp2p swarm (drops Swarm, closes connections)
     * gossip task (exits between peer announces)
     * sync worker (exits between batches)
     * operator heartbeat loop
     * rate-limit cleanup loop
     * peer-count metrics poller
     * the deferred DID-record publish task

   New GITLAWB_SHUTDOWN_GRACE_SECS (default 30s) controls how long
   axum waits for in-flight requests before returning.

2) Prometheus /metrics endpoint. New metrics module exposes:
     * gitlawb_info{version, did}                 (constant gauge = 1)
     * gitlawb_pushes_total{repo}                 (counter)
     * gitlawb_fetches_total{repo}                (counter)
     * gitlawb_auth_successes_total{route}        (counter, helper)
     * gitlawb_auth_failures_total{route,reason}  (counter, helper)
     * gitlawb_sync_queue_processed_total{status} (counter)
     * gitlawb_webhook_deliveries_total{result}   (counter)
     * gitlawb_pack_size_bytes                    (histogram)
     * gitlawb_peers_connected                    (gauge)

   Opt-in via GITLAWB_METRICS_ADDR (e.g. 127.0.0.1:9091). The endpoint
   is on a separate listener so the public port stays unexposed;
   bind to localhost or a private interface.

3) Metric instrumentation hooks in api/repos.rs (push/fetch),
   webhooks.rs (delivery outcomes), and sync.rs (per-item status).
   The auth helpers are defined but not yet wired — that's a
   focused follow-up.

Tests:
  * 3 new metrics::tests::* — encode returns valid prometheus text,
    record_* helpers are no-ops before init(), encode() errors if
    init() was never called.
  * cargo fmt + clippy -D warnings + cargo test --workspace all clean.

Closes the metrics and graceful-shutdown gaps in
docs/OSS-READINESS-AUDIT.md:131 and docs/MAINTAINER-ROADMAP.md:35.

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-06-02 08:58:05 +08:00
Vasanth TandClaude Opus 4.8 927e4d0cfb feat(db): versioned schema migrations with idempotent backfill (#21)
* feat(db): versioned schema migrations with idempotent backfill

Replace the inline &str SQL array that re-runs on every node startup with a
versioned migration system. Each migration is recorded in a new
schema_migrations table and applied at most once per node, inside a single
transaction.

For nodes upgrading from a pre-migration-versioning build, the canonical
`repos` table is used as a signal to mark v1 as already applied without
re-running its ~140 statements — so existing operators see zero behavior
change on first restart after this commit.

Closes the gap called out in docs/OSS-READINESS-AUDIT.md:78.

- Adds Db::migration_status() returning applied (version, name, applied_at)
- Adds 6 unit tests validating the static migration catalogue
  (versions strictly increasing, names distinct, bodies non-empty, v1
  name locked to initial_schema)
- cargo fmt + clippy -D warnings + cargo test --workspace all clean

* fix(db): make legacy migration backfill safe and lock-guarded

Address two robustness gaps in the v1 migration runner on the live network:

- Remove the "repos table present => v1 complete" backfill short-circuit.
  It assumed every existing node already had the full current schema; a node
  that was behind would be marked v1-applied while still missing newer tables
  or columns. Every v1 statement is idempotent (CREATE TABLE/INDEX IF NOT
  EXISTS, ADD COLUMN IF NOT EXISTS), so legacy installs now simply run v1 once:
  existing objects are no-ops, missing ones get created.

- Guard the whole runner with a Postgres advisory lock so two instances
  against the same database (blue/green or rolling deploy) can't race to apply
  the same migration and trip the schema_migrations primary key. The lock is
  released explicitly, and automatically if the connection drops.

Also document that per-migration transactions preclude CREATE INDEX
CONCURRENTLY in future migrations.

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

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-06-01 14:42:50 +08:00
Achille Wasque 924bccd8e5 feat(gitlawb-attest): External Attestation v1 for ref-update certs (#20)
Adds an optional `attestations` field on ref-update certs plus a small
crate to sign and verify them. An envelope with no attestations
serializes to identical bytes as a bare cert, so nothing on the wire
changes for nodes that don't opt in.

Each attestation carries a `type` discriminator (`covenant/exec/v1`,
`slsa/v1.0`, `sigstore/dsse/v1`, ...), an opaque payload, a `cert_hash`
that binds it to one specific cert, a `did:key` signer, and a base64url
ed25519 signature over a domain-separated JCS-encoded input.

## Wire shape

  cert_hash = SHA-256(JCS(cert_body \ {signatures, attestations}))
              encoded as lowercase hex.

  signing_input = b"gitlawb-attest-sig/v1\n"
                  || JCS({type, payload, cert_hash})

  sig = base64url-no-pad(Ed25519(signing_input))

The cert hash is reproducible across implementations because JCS pins
key order, number formatting, and string escaping (RFC 8785). Lowercase
hex is required on the wire: the JCS signing input includes `cert_hash`
verbatim, so accepting mixed case would force every signer and verifier
to normalize before computing the signing bytes — an easy spec rule to
miss across language ports.

The domain tag lives in the byte stream, not in the canonical JSON, so
an attestation cannot be confused with a same-shape JCS document signed
for a different protocol that uses the same Ed25519 key.

Signers are `did:key` over Ed25519 with the multibase base58btc (`z`)
prefix. Non-base58btc encodings of the same public key are rejected on
verify so allowlists and signer-pinning policies stay consistent across
peers.

## Verification

`Registry` maps type to verifier. `Policy::AcceptKnown` (default) lets
unknown types pass without trust so adoption stays incremental.
`Policy::RequireAll` enforces a per-repo allowlist but stays lenient on
unknown types in the batch — anyone can attach an attestation, so
short-circuiting on an unrelated `attacker/spam/v1` would be a DoS
vector. `Policy::RejectUnknown` is strict.

`AttestedRefUpdateCert::verify_attestations(&Registry)` is the one-call
convenience over `registry.verify_all(&env.attestations, env.cert_hash()?)`.

## Canonical test vectors

Ed25519, JCS, and SHA-256 are all deterministic, so a fixed seed plus a
fixed cert body produce exactly the same `cert_hash`, `signer` DID, and
`sig` across implementations. `tests/canonical_vectors.rs` pins these
values so a port to Go, JS, Python, or any other language has a concrete
oracle to check against without reading Rust. If a vector drifts, that
is a wire-protocol break.

## Tests

42 pass: 33 unit + 4 cross-crate integration against the real
`gitlawb_core::cert::RefUpdateCert` + 4 canonical vector pins + 1
rustdoc quick-start doctest.

`cargo fmt --all -- --check` clean.
`cargo clippy --workspace --all-targets -- -D warnings` clean.
`cargo test --workspace` green; the new crate breaks no existing suite.

## Not in this PR

  - Node integration: storage, GraphQL, register flow.
  - UI surfacing.
  - Trust-score weighting policy for typed attestations.
  - Rollout ordering: old nodes silently drop the new `attestations`
    field (`RefUpdateBody` does not set `deny_unknown_fields`); signers
    and verifiers roll out independently.
  - wasm / no_std: not targeted; ed25519-dalek and serde_json are
    std-only.

## Reference implementation

End-to-end demo at open-covenant/covenant@64ec780,
`agent-os/examples/gitlawb-attest-demo/` (`covenant/exec/v1`).

Closes #7.
2026-05-31 10:05:28 +08:00
R Sundareswaran a9f208a378 Feat/ucan chain validation (#19)
* feat(ucan): validate proof chains and attenuation

* feat(auth): validate UCAN chain in middleware

* feat(server): enforce UCAN chain on write routes

* docs(ucan): clarify wildcard attenuation semantics in doc comment

* test(node): add test-only constructors for Db and RepoStore

* fix(auth): harden require_ucan_chain middleware, lower log level, and add tests

* refactor(server): centralize and deduplicate auth middleware layering
2026-05-30 14:44:40 +08:00
R Sundareswaran 491978a809 Test/core crypto primitives (#16)
* test(core/cert): add RefUpdateCert validation coverage

* test(core/cid): add CID parsing and hash tests

* test(core/did): add DID validation and predicate tests

* test(core/identity): add keypair and signed payload tests

* style(did): wrap long assert! messages to satisfy rustfmt

* style(identity): wrap long assert! messages to satisfy rustfmt
2026-05-29 16:42:32 +08:00
Vasanth T 0b056f3ad0 [codex] fix Dependabot Rust alerts (#15)
* fix(deps): resolve dependabot rust alerts

* docs: clarify dependabot validation
2026-05-28 16:44:05 +08:00
Vasanth T d366fc6b97 [codex] chore: add live-safe OSS readiness pass (#14)
* chore: add live-safe OSS readiness pass

* fix(node): verify optional peer signatures during rollout

* docs: rewrite README for node roadmap
2026-05-28 15:34:47 +08:00
Kevin CodexandClaude Opus 4.7 b12c6bc328 feat: per-DID rate limiting on creation endpoints (10/hour) (#13)
Add sliding-window rate limiter applied to resource creation endpoints:
- POST /api/v1/repos (repo creation)
- POST /api/register (agent registration)
- POST /api/v1/repos/:owner/:repo/fork
- POST /api/v1/repos/:owner/:repo/issues
- POST /api/v1/repos/:owner/:repo/pulls

Returns 429 Too Many Requests with Retry-After header when exceeded.
Other write operations (merge, close, comment, star) are not limited
since they're bounded by existing resources.

Includes 5 unit tests covering: within-limit, over-limit, per-key
isolation, window expiry, and cleanup of stale entries.

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-27 12:29:02 +08:00
Kevin CodexandClaude Opus 4.7 14a1cdd656 chore: bump v0.3.9, fix all clippy warnings, enforce strict CI lint (#12)
* chore: bump v0.3.9, fix all clippy warnings, enforce strict CI lint

- Bump workspace version 0.3.8 → 0.3.9
- Fix 22 clippy warnings across 17 files:
  - Replace .last() with .next_back() on DoubleEndedIterators (8 instances)
  - Remove useless .into() conversions on anyhow::Error (3)
  - Remove needless borrows in gl init (3)
  - Replace map_or with is_some_and/is_none_or (3)
  - Collapse consecutive str::replace into char array pattern (2)
  - Change &PathBuf to &Path in function signatures (2)
  - Remove dead code: unused init_bare_repo function
  - Collapse if-let into outer match arm
  - Replace vec![...] with array literal
  - Introduce RecordAnchorInput struct (10 params → 1 struct)
  - Fix always-true boolean condition in gossip task guard
- Promote clippy from advisory to strict (-D warnings) in CI

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

* style: cargo fmt

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
v0.3.9
2026-05-27 10:28:14 +08:00
rapybus be099386e5 Update bootstrap-peers.json (#5) 2026-05-27 09:38:12 +08:00
b09714af0b fix(doctor): detect git-remote-gitlawb.exe on Windows (#10)
which_in_path() now also checks for `{name}.exe` on Windows targets,
so `gl doctor` correctly finds git-remote-gitlawb on that platform.

Co-authored-by: kelvinzer0 <[email protected]>
Co-authored-by: OpenClaude (mimo-v2.5-pro) <[email protected]>
2026-05-27 09:37:58 +08:00
Rimantas (Rimas) MoceviciusandRimas Mocevicius d09c23da86 feat: macOS menu bar app for managing gitlawb-node Docker Compose stack (#8)
- Menu bar icon with colored status dot (green/yellow/red)
- Start/Stop node via Docker Compose
- Settings UI: ports, public URL, postgres password, auto-sync, operator config
- View Logs window with live docker compose logs
- Auto-start on launch and login (SMAppService)
- Docker detection with install prompt
- Repo root detection via compile-time path for local builds
- Port variables wired through docker-compose.yml for custom port support
- GITLAWB_AUTO_SYNC passthrough for peer replication toggle
- Build script: scripts/build-macos-app.sh (codesign + DMG)

Co-authored-by: Rimas Mocevicius <[email protected]>
2026-05-27 09:37:36 +08:00
Esky33 f3df4a4dd4 Resolve bare DID aliases for trust and cert lookups (#9)
* Resolve bare DID aliases for trust and cert lookups

Public profile and CLI surfaces accept bare z6Mk keys, but trust and certificate data are stored against full did:key identities. Normalize agent profile lookups and make cert commands resolve owner-less repos against the caller DID so valid pushes are not hidden behind node-owned paths.

Constraint: Public URLs and CLI examples commonly use short z6Mk identifiers

Constraint: Ref certificates are issued after push and stored under the owner repo id

Rejected: Register duplicate bare-key identities | would split trust and certificate history across aliases

Rejected: Require full certificate UUIDs in show output | list already displays short IDs as the human-facing handle

Confidence: high

Scope-risk: narrow

Directive: Keep bare key inputs as aliases for did:key, not separate identities

Tested: cargo check -p gl

Tested: cargo run -q -p gl -- cert list agentbot-opensource

Tested: cargo run -q -p gl -- cert show agentbot-opensource 3686b1fe

Tested: cargo check -p gitlawb-node

Tested: cargo test -p gitlawb-node normalize_agent_did

* Preserve agent rank on short profile URLs

The public profile route can use compact handles like z6MkpUq1, while trust history is stored under the full did:key identity. Resolve exact aliases first, then fall back to matching registered agent key prefixes so older high-level identities are not displayed as fresh newcomers.

Constraint: Public GitLawb profile URLs expose short z6Mk handles.

Constraint: Trust and push history remain keyed by canonical did:key identities.

Rejected: Mint duplicate short-handle agent rows | would split rank and push history across aliases.

Confidence: high

Scope-risk: narrow

Directive: Keep short profile handles as aliases for canonical did:key identities, never as separate trust principals.

Tested: cargo fmt --check -p gitlawb-node

Tested: cargo test -p gitlawb-node agent

Tested: cargo check -p gitlawb-node

Tested: cargo check -p gl
2026-05-27 09:36:37 +08:00
Kevin CodexandOpenClaude 034cb3c80c feat: replica registration protocol (Phase 2 v1) (#4)
Phase 2 of the network-resilience plan: any node can register itself as a
replica of someone else's repo, and the origin exposes the replica list
publicly as a "your repo is mirrored by N nodes" trust signal.

What ships
- New repo_replicas table keyed by (repo_id, replica_did) with replica URL
  and registration timestamp. UNIQUE on (repo_id, replica_did); duplicate
  registrations update the URL.
- API on origin:
    PUT  /api/v1/repos/:owner/:repo/replicas   (auth) register
    DELETE /api/v1/repos/:owner/:repo/replicas (auth) unregister
    GET  /api/v1/repos/:owner/:repo/replicas   (public) list
  Origin rejects self-registration and validates the replica URL (http(s),
  no whitespace/control chars, length-bounded).
- CLI:
    gl repo replica-register <owner>/<repo> --url <my-public-url>
    gl repo replica-unregister <owner>/<repo>
    gl repo replicas <owner>/<repo>
- gl repo info now shows "Replicas: N" when the origin supports it.
- Tests: 5 new unit tests for the URL validator. Full node suite at 51 pass,
  full gl suite at 188 pass.

Deferred to Phase 2.5
- Replica auto-pulls the repo on registration (today: operator runs
  git clone gitlawb://... manually after registering).
- Periodic freshness sync worker driven by the replica list.
- Gossipsub subscription to push ref-update events to replicas live.

Co-authored-by: OpenClaude <[email protected]>
2026-05-27 09:36:12 +08:00
d2a440e521 Feat/backport perf fixes (#11)
* feat: replica registration protocol (Phase 2 v1)

Phase 2 of the network-resilience plan: any node can register itself as a
replica of someone else's repo, and the origin exposes the replica list
publicly as a "your repo is mirrored by N nodes" trust signal.

What ships
- New repo_replicas table keyed by (repo_id, replica_did) with replica URL
  and registration timestamp. UNIQUE on (repo_id, replica_did); duplicate
  registrations update the URL.
- API on origin:
    PUT  /api/v1/repos/:owner/:repo/replicas   (auth) register
    DELETE /api/v1/repos/:owner/:repo/replicas (auth) unregister
    GET  /api/v1/repos/:owner/:repo/replicas   (public) list
  Origin rejects self-registration and validates the replica URL (http(s),
  no whitespace/control chars, length-bounded).
- CLI:
    gl repo replica-register <owner>/<repo> --url <my-public-url>
    gl repo replica-unregister <owner>/<repo>
    gl repo replicas <owner>/<repo>
- gl repo info now shows "Replicas: N" when the origin supports it.
- Tests: 5 new unit tests for the URL validator. Full node suite at 51 pass,
  full gl suite at 188 pass.

Deferred to Phase 2.5
- Replica auto-pulls the repo on registration (today: operator runs
  git clone gitlawb://... manually after registering).
- Periodic freshness sync worker driven by the replica list.
- Gossipsub subscription to push ref-update events to replicas live.

Co-Authored-By: OpenClaude <[email protected]>

* perf: backport query optimizations + add Fly test deployment

- Add expression indexes (owner_short+name, updated_at DESC) for fast repo listing
- Add list_all_repos_with_stars() batch query eliminating N+1 star count calls
- Add list_all_repos_paged() with DISTINCT ON dedup + pagination + X-Total-Count header
- Add prune_self_peers() to remove stale self-referencing peer rows at startup
- Add self-loop prevention in HTTP peer notifications during git-receive-pack
- Add SwarmStatus observability to p2p_info endpoint (connected/mesh/all peers)
- Add fly.toml for gitlawb-node-test deployment in iad region

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

---------

Co-authored-by: OpenClaude <[email protected]>
Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-27 09:35:46 +08:00
Kevin CodexandOpenClaude f312955c35 feat: replica registration protocol (Phase 2 v1) (#3)
Phase 2 of the network-resilience plan: any node can register itself as a
replica of someone else's repo, and the origin exposes the replica list
publicly as a "your repo is mirrored by N nodes" trust signal.

What ships
- New repo_replicas table keyed by (repo_id, replica_did) with replica URL
  and registration timestamp. UNIQUE on (repo_id, replica_did); duplicate
  registrations update the URL.
- API on origin:
    PUT  /api/v1/repos/:owner/:repo/replicas   (auth) register
    DELETE /api/v1/repos/:owner/:repo/replicas (auth) unregister
    GET  /api/v1/repos/:owner/:repo/replicas   (public) list
  Origin rejects self-registration and validates the replica URL (http(s),
  no whitespace/control chars, length-bounded).
- CLI:
    gl repo replica-register <owner>/<repo> --url <my-public-url>
    gl repo replica-unregister <owner>/<repo>
    gl repo replicas <owner>/<repo>
- gl repo info now shows "Replicas: N" when the origin supports it.
- Tests: 5 new unit tests for the URL validator. Full node suite at 51 pass,
  full gl suite at 188 pass.

Deferred to Phase 2.5
- Replica auto-pulls the repo on registration (today: operator runs
  git clone gitlawb://... manually after registering).
- Periodic freshness sync worker driven by the replica list.
- Gossipsub subscription to push ref-update events to replicas live.

Co-authored-by: OpenClaude <[email protected]>
2026-05-13 20:00:40 +08:00
Kevin CodexandOpenClaude ec4ef81770 feat: embed bootstrap peers seed list for automatic network discovery (#1)
* feat: embed bootstrap peers seed list for automatic network discovery

A fresh \`docker compose up\` now joins the Gitlawb network with zero
manual peer configuration. The node parses an embedded \`bootstrap-peers.json\`
on startup and merges the entries into both the HTTP gossip task and the
libp2p Kademlia bootstrap.

- Add \`bootstrap-peers.json\` at repo root (versioned schema, PR-friendly)
- New \`bootstrap\` module in the node crate (parse + merge_seeds)
- Wire into \`main\` after \`Config::parse\`
- Operators can opt out via \`GITLAWB_BOOTSTRAP_DISABLE_SEEDS\` for
  isolated dev networks

Also in this commit:
- \`cargo fmt --all\` over the entire workspace (no logic changes)
- Downgrade CI clippy step to advisory (\`continue-on-error: true\`) until
  the existing lint backlog is cleared. fmt + tests stay strict.

Co-Authored-By: OpenClaude <[email protected]>

* test: cover bootstrap merge logic with unit tests

Refactor bootstrap.rs into pure functions (parse_seed_list, merge_into_vecs)
so the parse + merge logic can be tested without constructing a Config or
mutating process-global env vars.

Adds 11 tests covering:

- valid v1 list parses
- unknown version is rejected
- malformed JSON is rejected
- empty / missing peers array
- merge appends new http + p2p entries
- merge dedupes against existing entries
- invalid p2p_multiaddr is skipped (http still added)
- empty strings are skipped
- null optional fields are tolerated
- the canonical bootstrap-peers.json shipped in the repo always parses
  (regression guard against future schema changes)

Co-Authored-By: OpenClaude <[email protected]>

* fix(security): reject path traversal in repo_store local_path

Closes CodeQL alert #4 (Uncontrolled data used in path expression).

Both `owner_did` and `repo_name` come from URL parameters and were used
unsanitized to build a filesystem path, so a request like
`/did:key:foo/../../../etc/passwd.git/info/refs` could escape the repos
directory.

Fix:

- New `validate_owner_did` and `validate_repo_name` enforce a strict
  allowlist before path construction (alphanumeric + `: . _ -` for DIDs;
  alphanumeric + `. _ -` for repo names; rejects empty, `..`, leading
  `.` or `-`, slashes, backslashes, null bytes, overlong inputs).
- `RepoStore::local_path` now returns `Result<…>` and the 5 callers
  propagate the error.
- Defence in depth: even after the allowlist passes, the joined path is
  checked to still be rooted at `repos_dir`.
- 17 unit tests cover normal DIDs/repo names plus every malicious shape
  (`..`, `/`, `\`, leading dot/dash, null byte, overlong, did:web with
  dots).

Co-Authored-By: OpenClaude <[email protected]>

* fix(security): add Path::components barrier for CodeQL rust/path-injection

CodeQL's rust/path-injection query did not recognise the existing
allowlist validator as a sanitiser, so it kept flagging local_path even
though every malicious input was already rejected.

Adds an explicit Path::components() walk after path construction that
rejects any ParentDir or CurDir segment. This is the barrier pattern
CodeQL recognises — and is also genuine defence-in-depth in case a
future change ever weakens the allowlist.

Allowlist + starts_with + components walk = three independent layers
between attacker-controlled HTTP input and filesystem access. All 17
existing tests still pass.

Co-Authored-By: OpenClaude <[email protected]>

---------

Co-authored-by: OpenClaude <[email protected]>
2026-05-13 19:11:54 +08:00
Kevin CodexandOpenClaude 068fc76899 fix: nuke dummy binaries + fingerprints before second cargo build
Touch alone wasn't enough — cargo can still reuse cached fingerprint
state from the dependency-cache layer. Explicitly remove the dummy
binaries and their fingerprint dirs so the second build reproduces
real artifacts unconditionally.

Also drop the obsolete `version: '3.9'` from docker-compose.yml.

Co-Authored-By: OpenClaude <[email protected]>
2026-04-29 17:08:43 +08:00
Kevin CodexandOpenClaude 49dadf5172 fix: force cargo rebuild in docker layer
Touch real source files after COPY so cargo's fingerprint check
detects the change and rebuilds from real source. Without this,
cargo can keep the dummy `fn main() {}` binaries from the
dependency-cache layer, causing the runtime container to exit
immediately with code 0.

Co-Authored-By: OpenClaude <[email protected]>
2026-04-29 17:04:09 +08:00
Kevin CodexandOpenClaude 6b9c5278d4 chore: initial public release
Open-source release of the gitlawb node, CLI, and git remote helper.

- gitlawb-node: Axum HTTP server with git smart-HTTP and DID auth
- gl: command-line client (identity, repos, PRs, MCP server)
- git-remote-gitlawb: git remote helper for gitlawb:// URLs
- gitlawb-core: shared crypto primitives (DID, CID, HTTP sigs, UCAN)

Dual licensed under MIT or Apache-2.0.

Co-Authored-By: OpenClaude <[email protected]>
2026-04-29 16:34:27 +08:00