Files
node/docs/RUN-A-NODE.md
0a15e763d2 feat(node): owner-only push enforcement behind GITLAWB_ENFORCE_OWNER_PUSH (#31) (#68)
* 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]>
2026-06-24 11:03:28 +08:00

6.1 KiB

Running a gitlawb node

Step-by-step guide to staking $GITLAWB, registering your node on-chain, and earning protocol fees as a PoS operator.


Prerequisites

  • A wallet with at least 10,000 $GITLAWB (minimum stake) plus a small amount of ETH on Base for gas
  • Docker or Rust 1.91+ (for running the node process)
  • A public HTTP URL (your-host.com) — can be a VPS, Fly.io app, or anything reachable. A Fly.io config is provided at infra/fly/fly.toml (deploy from the repo root with fly deploy -c infra/fly/fly.toml)

1. Install the CLI

curl -fsSL https://gitlawb.com/install.sh | sh
# or build from source:
cargo install --path crates/gl

2. Create a gitlawb identity

Every node is identified by an Ed25519 DID keypair:

gl identity new
gl identity show
# → did:key:z6Mk...

This is your node DID — it's distinct from your Ethereum wallet.

3. Register your node on-chain

This stakes $GITLAWB and links your node DID to your operator wallet:

export GITLAWB_OPERATOR_PRIVATE_KEY=0xYOUR_KEY
export GITLAWB_TOKEN=0x5F980Dcfc4c0fa3911554cf5ab288ed0eb13DBa3
export GITLAWB_CONTRACT_NODE_STAKING=0xNODE_STAKING_ADDR
export GITLAWB_CHAIN_RPC_URL=https://mainnet.base.org

gl node register \
  --stake 10000 \
  --http-url https://my-node.example.com

What this does:

  1. Checks your $GITLAWB balance
  2. Calls token.approve(NodeStaking, 10000e18) if needed
  3. Calls NodeStaking.registerNode(didHash, httpUrl, 10000e18)
  4. Transfers 10,000 $GITLAWB into escrow

4. Run the node

Option A — Docker

docker run -d \
  --name gitlawb-node \
  -p 7545:7545 \
  -p 7546:7546/udp \
  -v gitlawb-data:/data \
  -e DATABASE_URL=postgresql://user:pass@host/gitlawb \
  -e GITLAWB_PUBLIC_URL=https://my-node.example.com \
  -e GITLAWB_OPERATOR_PRIVATE_KEY=$GITLAWB_OPERATOR_PRIVATE_KEY \
  -e GITLAWB_CONTRACT_NODE_STAKING=$GITLAWB_CONTRACT_NODE_STAKING \
  -e GITLAWB_CHAIN_RPC_URL=$GITLAWB_CHAIN_RPC_URL \
  -e GITLAWB_OPERATOR_STRICT_MODE=true \
  ghcr.io/gitlawb/node:latest

Option B — docker-compose

See docker-compose.yml — bundles node + Postgres.

Option C — From source

cargo run -p gitlawb-node --release

Required env for on-chain PoS mode:

  • GITLAWB_CONTRACT_NODE_STAKING — contract address
  • GITLAWB_OPERATOR_PRIVATE_KEY — key that posts heartbeats
  • GITLAWB_CHAIN_RPC_URL — Base RPC URL (default Sepolia)

Optional:

  • GITLAWB_OPERATOR_STRICT_MODE=true — refuse to start if not registered or not currently active
  • GITLAWB_HEARTBEAT_INTERVAL_HOURS=20 — how often to post heartbeats (must be < 24)

5. Verify

gl node onchain-status

Expected output:

On-chain status for did:key:z6Mk...

  Operator wallet:  0x...
  Staked:           10000 $GITLAWB
  HTTP URL:         https://my-node.example.com
  Last heartbeat:   1760000000 (unix)
  Active flag:      true
  Currently active: true
  Pending rewards:  0 $GITLAWB

Check your node logs — you should see operator heartbeat loop starting and a heartbeat tx within a few seconds of startup.

6. Earn rewards

The node's heartbeat loop runs every 20 hours. As long as you stay active:

  • Every Sunday, the FeeDistributor distributes accumulated fees.
  • 75% of the weekly pot is split across all active nodes (pro-rata by stake).
  • Your share accrues as pendingRewards on-chain — claim anytime without unstaking.
gl node claim

7. Unstake (if you want out)

7-day cooldown, two steps:

# Step 1: request unstake (starts 7-day timer)
gl node unstake-request

# ... wait 7 days ...

# Step 2: complete — returns stake + any pending rewards
gl node unstake

During the cooldown your node still earns rewards if it keeps heartbeating.


Hardening: owner-only push

By default the node authenticates every git-receive-pack push (a valid RFC 9421 did:key signature) but does not check that the pusher owns the repo, except on branches that are explicitly protected. Because did:key is self-certifying, any party can generate a key, derive its DID, sign, and push to an unprotected branch — authentication is not authorization.

To require the authenticated pusher to be the repo owner on every branch, set:

GITLAWB_ENFORCE_OWNER_PUSH=true
  • Default false — preserves current behavior so live nodes are unaffected by an upgrade. Turn it on once you're ready for owner-only writes.
  • When true — a push whose authenticated DID is not the repo owner is rejected (HTTP 403) before any ref update is applied. The owner is matched in both the full did:key:z6Mk… form and its bare z6Mk… suffix.
  • Caution: this blocks every non-owner pusher, including your own delegated and CI agents. Push authorization is owner-only today — a UCAN git/push capability is verified but not yet honored for authorization, so delegated keys cannot push while this is on. Don't enable it until every identity that pushes to your repos is the owner, or you'll lock out your own automation. Scoped collaborator / UCAN-delegated push rights are a planned follow-up.

Operational checklist

Concern Recommendation
Heartbeat gas ~$0.03/month on Base L2 — negligible
Missed heartbeats After 3 days without one, you're excluded from rewards until you beat again
Operator key Dedicated wallet, small ETH balance, not your main treasury
Monitoring Watch lastHeartbeat on-chain; alert if > 22h since last beat
Public URL Must resolve and serve /health — peers will ping it

Troubleshooting

"insufficient $GITLAWB balance" — fund the operator wallet with at least 10,000 $GITLAWB.

Node refuses to start with "strict-mode operator check failed" — either gl node register first, or unset GITLAWB_OPERATOR_STRICT_MODE.

Rewards are 0 after a week — run gl node onchain-status. If currentlyActive: false, check your heartbeat loop (node logs for operator heartbeat sent).

Want to rotate operator wallet — requires unstake → re-register with new wallet. No in-place rotation in v1.


See ECONOMICS.md for the full reward math.