Resolve conflicts: keep the SHA-256 advisory-lock key change alongside
upstream's connection-affine RepoWriteGuard rework and its F1/F3b/F4/U8
test suite; RUN-A-NODE.md keeps both the updated Public URL row and the
rolling-upgrade advisory-lock row.
Two doc drifts jatmn flagged on #173, both docs-only.
The /ipfs walk cap is min(internal ceiling, GITLAWB_IPFS_MAX_REPOS_WALKED),
where the internal ceiling is MAX_PIN_SOURCES + 1 = 17. config.rs, README, and
.env.example all described a max() floor instead, so an operator reading them
would think the default of 64 was the effective ceiling and that raising the
knob bought headroom. Neither is true. The runtime is not changed: two tests set
the knob to 1 and depend on the cap actually being 1, so wiring the documented
floor would make the knob inert and break them.
db_max_connections has defaulted to 48 for a while and four comments still said
20. Two of them did arithmetic on it. main.rs and repo_store.rs justified the
separate advisory-lock pool by "20 is below 32", which is now false and made the
separation look like a consequence of the defaults; it is structural, since a
push holds its connection for the whole receive-pack at any pool size. Also adds
a RUN-A-NODE troubleshooting entry for the boot failure an in-place upgrade hits
when it is still configured with the old pool size of 20.
DefaultHasher is not frozen by the Rust standard and has shifted across
toolchain versions, so the same (owner_slug, repo_name) produced a
different i64 advisory-lock key on different builds and broke
cross-machine write-exclusion for the same repo.
Hash with SHA-256 instead and pin the result with a golden-value test,
plus a one-axis-at-a-time differential test so a regression that drops
either parameter is caught. Document the SHA-256 re-keying caveat on
acquire_write and in the RUN-A-NODE.md operator checklist (drain writes
or cut over single-node during a rolling upgrade).
* 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>
* feat(node): owner-only push enforcement behind GITLAWB_ENFORCE_OWNER_PUSH (#31)
A valid did:key signature on git-receive-pack is authentication, not
authorization: any party can generate a key, derive its DID, sign, and push to
an unprotected branch. Owner checks only ran on protected branches, so a
non-owner could write to unprotected branches of an arbitrary repo.
Add an opt-in flag GITLAWB_ENFORCE_OWNER_PUSH (default false, mirroring
GITLAWB_REQUIRE_SIGNED_PEER_WRITES) that requires the authenticated pusher to be
the repo owner on every branch. Default-off keeps live nodes unaffected.
Design follows the maintainer's review on #31:
- Authorize on the canonical AuthenticatedDid injected by require_signature, via
Extension<AuthenticatedDid> on the handler — not a re-parse of the headers.
The legacy branch-protection check is left as-is.
- Fail closed: an absent identity or a non-owner caller is rejected. The policy
lives in a pure fn (owner_push_rejection) so it is unit-tested without a DB.
- The gate runs before branch protection, so one rejection yields one error body.
- Factor the duplicated owner-match into auth::is_repo_owner, reused by
receive-pack, protect.rs, and visibility.rs (each keeps its own message).
events.rs / peers.rs are left out — they aren't owner checks.
- Name the push gate by intent (caller_authorized_to_push) so the planned
Phase 2 UCAN git/push capability is a pure addition, not a rewrite.
Tests: owner (full + short DID) allowed, non-owner rejected, missing-DID
rejected, flag-off legacy. Docs: .env.example + docs/RUN-A-NODE.md.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
* fix(node): address review on #68 — 403 status, finish owner-match consolidation
Per @beardthelion's review:
- Return 403 Forbidden (not 400) for authorization denials: the owner-push gate,
the branch-protection denial, and the owner checks in protect.rs / visibility.rs.
400 reads as a malformed request and some git/CI clients retry it.
- Finish the is_repo_owner consolidation: route the branch-protection check and
the repos list filter through the helper. The branch-protection check now uses
the verified AuthenticatedDid (auth.0) instead of re-parsing the headers.
- Drop the second identity source on the push path entirely: the post-push event
recording also uses auth.0, so extract_did_from_auth (and the handler's
HeaderMap param + import) are removed.
- Tests assert the Forbidden variant (so a future status regression fails loudly)
and add direct coverage for is_repo_owner / caller_authorized_to_push.
- Docs: warn that enabling the flag blocks delegated and CI agents until the
Phase 2 UCAN git/push capability is honored.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
---------
Co-authored-by: FrozenRaspberry <[email protected]>
Co-authored-by: Claude Opus 4.8 <[email protected]>
* 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]>