* fix(node): gate agent-task reads behind the same visibility rules as repo data list_tasks and get_task had no authorization at all: any anonymous caller could enumerate every task on the node, including another party's repo-less task, its ucan_token, and its payload (#268). Add task_visible, mirroring the repo read-visibility gate already used by the ref-updates feed: the delegator and assignee can always read their own task, a repo-scoped task follows that repo's normal visibility rules, and a task naming no repo (or a repo this node doesn't host) is visible only to its delegator/assignee. Both REST and GraphQL now route through the same collect_visible_tasks/get_visible_task collectors so the two surfaces cannot drift, and neither read path echoes ucan_token back, since the holder already received it via the create/claim response. Fixes #268 * fix(node): query the task limit-clamp test as the delegator tasks_limit_ceiling_clamped_to_200 seeded 201 repo-less tasks and read them back anonymously, expecting all 200. That read is exactly the enumeration #268 closes, so the new visibility gate correctly returns none of them and the test went red. The clamp ceiling is what this test pins, not the gate, so query as the tasks' delegator, who can legitimately see all 201 rows. Refs #268 * fix(node): scope task visibility lookups to the page's repos collect_visible_tasks loaded every repo on the node and every visibility rule in order to gate at most 200 tasks, so an anonymous request paid for the whole node's repo and rule set. Narrow both lookups to the repo ids the fetched page actually names, and skip them when no task names a repo. The deduped repo snapshot stays the source of truth for resolving a repo_id: it collapses mirror and canonical pairs and omits quarantined repos, and an id missing from it has to keep failing closed. Resolving ids straight from the repos table would surface exactly those withheld rows. Add GraphQL denial tests as well. Nothing pinned that the task resolvers delegate to the shared collectors, so a resolver that queried the database directly would not have gone red. Refs #268 * Preserve authorized task reads across shipped clients. Refs #268 * Bound denied task history scans. Refs #268 * Signal incomplete task scans with recoverable cursors and map read errors to AppError. Refs #268 * Stop disclosing denied task rows in the scan-wall cursor and add GraphQL pagination state. Refs #268 * Normalize task cursors and gate mutation endpoints behind visibility. Canonicalize RFC 3339 timestamps in parse_after_cursor to handle URL-decoded spaces, reject mixed cursor alias families, gate complete_task and fail_task behind get_visible_task so unreadable tasks 404 instead of leaking existence with 403, and only flag incomplete when hitting candidate ceilings on full SQL batches. Refs #268 * Update complete_task test in test_support to exercise both 404 on unreadable and 403 on non-assignee tasks. Refs #268 * Bind task to repo id in complete_task test in test_support. Refs #268 * Preserve timestamp fractional width and anchor continuation tests on legitimate cursors. Refs #268 * Match on Value::Object to extract task ID in GraphQL continuation test. Refs #268 * Remove unused mut binding on visible_newer in GraphQL query test. Refs #268 * Hide unauthorized claim and keep assigned tasks from being stolen Gate REST and GraphQL claim behind the same visibility check as complete and fail, refuse claim when another assignee already holds the task, and only broadcast publicly visible task events. Treat a full list page as incomplete when more candidates remain. Surface HTTP errors from CLI and MCP claim and complete helpers. Refs #327 Co-Authored-By: cairn-code <[email protected]> * Keep task-list incomplete for the scan ceiling only. A full visible page was flagged incomplete whenever the SQL batch was full, so the first page of any list with more than 200 candidates looked stalled. Align the GraphQL claim test with the visibility gate's not-found message. Refs #268 * fix(node): pin claim guards and map mutation errors through AppError Review required tests that go red if the pre-assigned claim predicate or the anonymous announce gate is deleted, and incomplete must not stay true when the candidate stream is exhausted at the scan ceiling. Route claim, complete, and fail through AppError so closed-pool outages stay 503 and 404s match the read envelope. Refs #268 * fix(node): match bare and did:key assignee forms in claim and list create_task stores the supplied assignee unchanged, so a raw SQL equality check drops a designated assignee who presents the other did:key form. Compare the normalized key so claim and filtered list agree with did_matches. Refs #268 * fix(node): check task creation status and add assignee expression index - Add error_for_status() to cmd_create and task_create MCP tool - Update test_create_task_server_error to assert failure on 500 - Add migration v18 creating expression index idx_agent_tasks_assignee_key matching ASSIGNEE_DID_CASE_SQL - Add did:web:z6Mkfoo single-residual shape to parity boundary matrix Refs #327 # Conflicts: # crates/gitlawb-node/src/db/mod.rs * fix(node)!: Replace task cursors with opaque continuation tokens The task read path treated visibility, pagination, and error vocabulary as separate edits, so each one broke where they met. Rework them as one contract. A raw (created_at, id) cursor forced a choice between two broken options: it could name the last visible row, and then a denied window longer than the 1,000-candidate scan budget was unpageable forever; or it could name the last examined row, and then a denied read leaked the id and timestamp of a task GET /tasks/{id} otherwise 404s. Continuation tokens remove the choice. They carry the last examined candidate, so paging always advances a full scan budget per request, and they are encrypted and authenticated under a node-derived key, so the caller learns nothing from one and cannot forge one naming a row of their choosing. Encryption is a synthetic-IV construction over the hmac/sha2 pair already used for webhook signatures, so it adds no dependency and needs no randomness source. Making the token the only accepted cursor also gives the ordering key one domain. agent_tasks.created_at is TEXT and compared as TEXT, so a caller-typed '...Z' and '...+00:00' denote one instant but sort differently, and a client could silently skip or repeat same-time rows. The token carries the stored string verbatim, so the value compared is always one the server wrote. The raw after_*/cursor_* pairs are removed rather than kept alongside it, since a second domain is the bug. Separate the two facts the old single incomplete flag conflated: has_more says candidates remain, incomplete says this page is short only because the authorization scan hit its ceiling. Both REST and GraphQL now return has_more, incomplete, and next_cursor from the shared collector, and REST echoes the limit it actually applied so a clamped request is visible as clamped. Have gl task list and MCP task_list follow next_cursor instead of issuing one request: --limit 500 returned a successful but silently truncated 200 rows. Following is bounded by a page cap and a no-progress guard, and a run stopped by either reports an explicit incomplete result with a resume cursor. Route claimTask, completeTask, and failTask through the same task_write_conflict classifier the REST handlers use, via curated helpers in the graphql module so the map_err source guard still holds. A claim race or stale finish reached GraphQL clients as a generic database error while REST clients got an actionable conflict; genuine sqlx faults stay opaque on both. Refs #327 * fix(node): End the candidate stream only after the batch is examined A short SQL batch means no rows exist past it, not that every row in it was examined. When the page filled mid-batch the collector treated the two as the same, marked the stream ended, and suppressed the continuation, so every row after the one that filled the page was unreachable. The equal-timestamp paging tests caught it: three rows with a limit of one returned only the first. Track how much of each batch was consumed and end the stream only when the whole of a short batch has been examined. Otherwise leave `has_more` to the probe row, which resumes from the last examined candidate. Refs #327 * fix(gl): Reject a non-positive task-list limit and fault the write under test A `--limit 0` reached the node, which clamped it to zero and answered with an empty page marked complete, so an invalid request read as proof that no tasks exist. Reject a non-positive limit in `fetch_tasks()`, the helper the CLI and MCP share, so the guard cannot drift between the two surfaces. `task_write_sql_faults_stay_opaque` did not exercise what it named. Dropping `updated_at` also broke the SELECT in `get_task()`, so the fault surfaced from the `get_visible_task()` pre-check through `graphql_app_err` and never reached `graphql_claim_conflict`. A `BEFORE UPDATE` trigger keeps every read valid and faults only inside `Db::claim_task`, and the test now also asserts that a write-time fault is not reclassified as a claim race. Refs #268 * fix(node): Bind task cursors to the caller and brake the task read routes A continuation token names the last candidate a scan examined, not the last row it returned, so it encodes how far that scan got under one caller's visibility. The MAC bound the page filter but not the presenting identity, so resuming a token as a different caller started the scan past rows that caller was entitled to read and dropped them from the answer with nothing to signal the loss. Bind the caller's normalized DID into the MAC, with anonymous flagged absent rather than encoded as empty. Normalization goes through normalize_owner_key so the two spellings of one did:key identity bind identically, matching did_matches on the read path: a caller who presents the other form of their own DID keeps their own page. A mismatched token renders the existing single rejection message, so this adds no oracle. GET /api/v1/tasks and GET /api/v1/tasks/{id} are anonymously reachable, and the visibility gate costs a task lookup plus deduped-repo and visibility-rule queries before it can return the opaque 404. An unauthenticated prober therefore pays nothing while the node pays per request, whether or not the id exists. Attach the per-IP limiter already used on /ipfs/{cid}, configurable through GITLAWB_TASK_READ_RATE_LIMIT and swept by the periodic task like every other per-key limiter. Refs #268 * fix(node): Brake anonymous GraphQL task reads on the same per-IP bucket The per-IP brake added for the task read routes covered only /api/v1/tasks*, so an anonymous caller reached the same collect_visible_tasks and get_visible_task gate over /graphql with no bucket at all. The fence had an open lane beside it. Carry the brake as GraphQL request data and debit it in the tasks and task resolvers rather than layering rate_limit_by_ip onto the GraphQL router: /graphql is one endpoint for every operation, so a router layer would charge unrelated queries and every mutation against the task-read bucket. Debiting per resolved field also prices an aliased query honestly, since ten aliased tasks fields run the gate ten times. Extract RATE_LIMIT_MESSAGE so the GraphQL surface, which cannot return a 429 status inside a 200 envelope, refuses with the same text the REST routes use. /graphql/ws serves the query root as well and stays unbraked; closing it needs a WebSocketUpgrade handler and is left for a follow-up. Refs #268 Refs #327 * fix(gl,node): parse typed task pages, track cursor cycles, and normalize assignee filter MAC * fix(node): Cap aliased GraphQL task reads per request and derive has_more from visible rows - Cap aliased GraphQL task read fields per request using an atomic counter on TaskReadBrake (MAX_GRAPHQL_TASK_READS_PER_REQUEST = 5). - Derive has_more in collect_visible_tasks by scanning for bounded_limit + 1 visible rows, eliminating the un-gated keyset probe that could leak the presence of trailing denied tasks. - Add regression tests covering aliased GraphQL capping and trailing denied task has_more privacy. Refs #327 * fix(node): Check database continuation when scan budget ends on exact batch boundary When candidate scanning reaches MAX_TASK_SCAN_CANDIDATES without finding a target_visible row and the final batch was full, probe the database for rows beyond the scan position so an exhausted candidate stream is not erroneously marked incomplete. Refs #327 * fix(node): Document and pin the bound on the task scan-ceiling probe The scan-ceiling branch of collect_visible_tasks settles has_more with an un-gated LIMIT 1 probe, so a caller can learn whether any row - readable or not - trails the position the scan stopped at. Withholding the probe does not remove that bit: enumeration past a denied window longer than one scan budget requires handing back a continuation, and following that continuation returns the same terminal page one round trip later. State what the probe discloses (one bit, only at server-chosen positions a full scan budget apart, reachable only through a MAC'd cursor, never a denied row's id, payload or ucan_token) and pin it end to end. Also correct the comment above the branch, which claimed has_more never comes from an un-gated probe while the code below it did exactly that. Refs #327 * Index task keyset pages so the scan ceiling bounds database work. Optional IS NULL predicates kept the planner from using a created_at/id order, so every list_tasks_keyset batch could sort a growing match set before LIMIT. Dedicated per-domain SQL plus v28 indexes make the candidate ceiling a database bound. Refs #327 * Reject oversized task pages so list never returns more than the requested limit. fetch_tasks asked for the remaining total, then appended every row on a valid-shaped page. A remote that sent more tasks than want could make gl and MCP expose more than --limit. Treat that page as protocol-invalid before any extra row is kept. Refs #327 * Enforce task page limits on legacy responses and drop redundant single-column indexes. Refs #327 * Address review feedback on task-read authorization gating and client safety. - Wire TaskReadBrake into /graphql/ws subscriptions and verify per-request field caps and per-IP rate limiting over WebSocket connections. - Define open-claim eligibility separately from read visibility so unassigned tasks on readable/unscoped domains can be claimed without making task bodies enumerable. - Propagate identity errors on explicit key directories in CLI and MCP instead of silently falling back to anonymous mode. - Enforce response byte limits before deserializing task pages, validate row schema and page-local uniqueness before row commit, and sanitize continuation cursors in terminal diagnostics. - Add GraphQL denial assertions for repo-less tasks and token isolation. Refs #268 Refs #327 * Address review feedback on task-read authorization gating, WebSocket budgets, and test framing. Refs #395 * Address review feedback on quarantined task claims, WebSocket auth, and MCP limit parsing. Refs #395 * Reject forged signatures during WebSocket authentication upgrade. Refs #395 * Accumulate forged WebSocket handshake response until headers terminate. Refs #395 * Withhold quarantined-repo tasks on all read surfaces before party checks. Refs #395 * docs: Align task-read documentation with enforced visibility and rate limits. Refs #395 * docs: Distinguish task list and get behavior in rate-limit guidance. Refs #395 * fix(node): Batch task quarantine checks to bound read costs. Refs #395 * docs(node): clarify the shared task-read budget Document that GraphQL task queries over HTTP and WebSocket debit the same per-IP hourly budget as REST task reads. Refs #395 * fix(node,gl): bound scoped repo lookup and validate MCP cursor Bound repository access in list_repos_deduped_by_ids to requested groups via idx_repos_owner_key_name, leaving dedup_cte unscoped. Strictly validate MCP task_list cursor argument before network dispatch. Refs #396 * test(db): verify scoped lookup work in prepared plans Measure discarded rows and verify actual custom and generic prepared-plan counters, including the previous full-index-scan regression. Fixup fora0ea4c78e4. Refs #396 * docs(db): remove stray dedup comment escapes Restore readable root-path notation in the deduplicated listing documentation. Fixup fora0ea4c78e4. Refs #396 * fix(node): address #464 review findings - Hold migration 27/28 numbering: reservation comment now names the competing Gitlawb/node#384 range (27-35) and states the numbering is on hold pending #384. No renumber. - Restore crates/gl/src/identity.rs to LF endings so the diff shows only the added load_optional_keypair function. - Drop the dead single-column idx_agent_tasks_assignee_key build from v27 (v28 builds the keyset-ordered variants directly); adjust the v27 test to assert the raw index is dropped and the expression index is never built. - Align task_claimable with claim_task's conditional write: drop the delegator short-circuit and correct the docstring. - Pin error_for_status on the gl task list path with a 403 mock that serves a well-formed empty page, so the test goes red if the call is removed. * test(node): pin the delegator deny on a pre-assigned claim - Add claim_task_delegator_cannot_claim_preassigned_task: pins opaque 404 for delegator claiming a pre-assigned task (regression guard for P2) - Fix test_list_tasks_server_error_surfaces regex: /api/v1/tasks\? -> /api/v1/tasks\? (escaped ? for literal question mark) - Assert actual reqwest StatusCode::FORBIDDEN instead of matching "403" in error string (avoids spurious pass from random mock port) * test(node): exercise streamed byte-budget guard with chunked body Use with_chunked_body instead of with_body so mockito sends no Content-Length, pinning the no-length streamed accumulation guard. Assert the "exceeded" message to distinguish from the declared-length check. * fix(node): pin declared-length byte-budget arm in oversized content-length test Addresses beardthelion review finding (2026-09-26): test_read_task_page_json_oversized_content_length now asserts on the "(declared" arm the way the chunked twin asserts "(exceeded", so the Content-Length pre-check at crates/gl/src/task.rs:211 is genuinely pinned. Verified the pin by neutering the pre-check (test goes red), then restored. fmt clean. * fix(node): fail closed on claim for unresolvable repo task_claimable returned true for an unassigned task whose repo_id is slash-form (mirror row) or names a repo this node does not host, while task_visible fails closed on the same rows. A signed stranger could claim such a task even though reads 404. Return false for a Some(repo_id) the node cannot resolve to a locally-hosted repo, matching task_visible's fail-closed check; keep the None arm open (repo-less tasks claimable by design). Adds a regression test asserting a stranger's claim 404s and leaves the assignee unchanged for both row classes. * fix(node): pad sealed task cursor to fixed width The scan-ceiling token seals the last examined row's (created_at, id) as variable-length JSON under a length-preserving keystream, so token length leaked the withheld row's field widths: a 32-char to_rfc3339() rendering mints a 148-char token where a 35-char one mints 161 chars. Pad the serialized payload with trailing JSON whitespace to a fixed 128-byte bucket before the SIV tag is computed; decode tolerates the padding (serde_json ignores trailing whitespace) and tokens minted before this change still verify. REST and GraphQL share task_cursor::encode, so one fix covers both. Adds token_length_does_not_vary_with_position_width: asserts identical token length across narrow/wide timestamp positions and that the padded token still decodes to the exact stored position. * fix(node): address beardthelion round-six P3 findings on task cursor - Pin the encode docstring compat claim: add unpadded_legacy_token_still_verifies test minting a token via the pre-padding recipe (siv + apply_keystream with no padding) and asserting decode returns the position. - Pin the PAYLOAD_BUCKET round-up claim: add oversized_payload_rounds_up_to_next_bucket test with a 140-char id crossing the 128-byte bucket, asserting round-trip at two buckets. - Correct timestamp-width figures: to_rfc3339() renders 25 chars with no fractional seconds (not 32); 32 is the microsecond width. * fix(node): address beardthelion round-seven P3 findings on task cursor - Correct wire-form docstring to v1.<iv>.<body> - Fix payload width figures: 101 bytes total, to_rfc3339() precision ladder - Tighten oversized-payload test to assert exactly 2*PAYLOAD_BUCKET - Pin legacy back-compat test with a frozen v1 token fixture * test(node): address beardthelion round-eight P3 findings on task cursor - Pin the one-bucket width: round_trips_position_verbatim now asserts the decoded body of a normal token is exactly PAYLOAD_BUCKET, catching a pad that skips next-multiple or a drifted bucket constant. Every other width assertion here is bucket-relative, so only an absolute check goes red. - Assert the frozen fixture is the unpadded shape: unpadded_legacy_token_still_verifies decodes the body segment and asserts raw.len() < PAYLOAD_BUCKET, so a literal re-minted under the padded recipe would retire the back-compat pin loudly instead of passing silently. - Narrow the recipe-pin claim: the docstring now names what the frozen literal actually binds (domains, MAC input, keystream, segment layout, field names) instead of claiming padding and serialization changes break the test. The padding-width claim stays with oversized_payload_rounds_up_to_next_bucket. * fix(node): trim stored assignee on every task gate; align SECURITY.md task-read wording * test(node): pin mirror-row list-vs-get asymmetry; fix plan-test bind (#553) - EXPLAIN helper binds normalize_owner_key(assignee) to match the production list_tasks_keyset bind. - Pin the deliberate asymmetry jatmn asked about: a delegator sees a slash-form (mirror-hosted) task in a list, while get fails closed for every caller because the row has no local repo backing. Notes in get_visible_task and a regression test keep it from drifting. - Repair test-mod drift from the main merge: build_schema gains its TaskCursorKey arg at three sites and list_tasks calls move to list_tasks_keyset. * test(node): reconcile tasks complexity contract with TaskPageType (#553) The four GraphQL task/brake tests failed on this branch: contract tests still queried the flat tasks { id } shape that TaskPageType replaced, and the alias-brake tests ran default-size lists whose six aliases exceed GRAPHQL_MAX_COMPLEXITY before the brake can debit. - Contract tests now select through items { ... } with refreshed cost arithmetic. - Brake tests use tasks(limit: 1), keeping six aliases under the budget so the 6th field reaches TaskReadBrake and fails RATE_LIMIT_MESSAGE. Complexity stays the outer gate (#552); the brake stays the per-request field-count gate. * docs: note mirror-hosted task list-vs-get asymmetry (#553) Option 3 for slash-form mirror repo_id rows was pinned in code and test at 6c0926de; this adds the operator-facing half jatmn asked for. README and SECURITY task-read bullets now state that mirror rows list for party readers while get-by-id and GraphQL task fail closed for all callers. * ci: retrigger codeql scan after alert 86 dismissal --------- Co-authored-by: euxarist <[email protected]> Co-authored-by: cairn-code <[email protected]> Co-authored-by: cairn-intern <[email protected]> Co-authored-by: Marlowe <[email protected]>
35 KiB
Twigpine Node
Decentralized git infrastructure for developers, AI agents, and app delivery.
Twigpine Node is the open-source node software behind the Twigpine network. It lets anyone run a self-hosted node, publish repositories under a DID, sign writes with Ed25519 HTTP signatures, replicate git activity across peers, and move toward a resilient app-delivery network where code and build assets can be served closer to users.
Twigpine is not trying to be only "another git host." The long-term direction is:
Decentralized GitHub
+ signed agent-native workflows
+ resilient repo replication
+ CDN-style app/code delivery
The mission is simple: once code is pushed to the network, it should not disappear because one server went down.
What is in this repository?
This is a Rust workspace with six crates:
| Crate | Purpose |
|---|---|
gitlawb-node |
The node daemon: Axum HTTP server, git smart-HTTP, Postgres metadata, libp2p gossip, optional S3/Tigris/IPFS/Arweave/Base PoS hooks. |
gl |
The Twigpine CLI for identity, repos, issues, PRs, bounties, tasks, peers, node status, MCP, and setup flows. |
git-remote-gitlawb |
Git remote helper for gitlawb:// URLs, so normal git clone, git fetch, and git push can talk to Twigpine nodes. |
gitlawb-core |
Shared primitives: Ed25519 identities, did:key, CIDs, RFC 9421 HTTP signatures, certificates, and UCAN tokens. |
gitlawb-attest |
Pluggable external provenance attestations for ref-update certificates. |
icaptcha-client |
Client for the iCaptcha proof-of-intelligence flow used to protect spam-prone writes. |
Why Twigpine Nodes?
Most git hosting today depends on a small number of centralized platforms. Twigpine Nodes are designed for a different model:
- Own your identity: every user, agent, and node is an Ed25519 keypair represented as
did:key:z6Mk.... - Signed writes by default: write requests use RFC 9421 HTTP Signatures instead of passwords.
- Git-native transport: repositories are still real git repositories served over smart HTTP.
- Agent-native workflows: the
glCLI and MCP server expose repo, issue, task, PR, and UCAN flows to AI agents. - Peer-aware delivery: nodes can announce, discover, gossip, and sync with each other.
- App CDN direction: the network can evolve from decentralized code storage into code + asset + app delivery.
Current status
Twigpine Node is live early infrastructure. It is useful today, but some security and reliability features are intentionally staged for compatibility with existing nodes.
Good today:
- Local or Docker node startup.
- Postgres-backed repo metadata.
- Bounded GraphQL queries with repository pagination.
- Bare git repository storage.
- Git smart-HTTP clone/fetch/push.
- RFC 9421-signed writes.
- Repository and path-scoped visibility enforcement for repository and Git content reads, with 404-shaped repository denials.
- DID identities.
glCLI workflows.- libp2p peer discovery/gossip foundation.
- Optional Tigris/S3 storage.
- Optional IPFS/Pinata and Arweave/Irys hooks.
- Optional Base node-operator staking/heartbeat hooks.
Known limitations:
- Repository write authorization is secure by default:
GITLAWB_ENFORCE_OWNER_PUSHdefaults totrue, so a valid HTTP Signature identifies a pusher and the node additionally requires that pusher to be the repository owner. SetGITLAWB_ENFORCE_OWNER_PUSH=falseonly for the duration of a rolling upgrade whose pushers are not yet the repo owner. Authentication is not authorization on its own (anyone can mint adid:keyand sign), so owner enforcement is the only thing that prevents every signed caller from pushing to every repository, private ones included. - UCAN proof chains are validated when supplied, but UCAN capabilities are not consulted by write authorization and the root issuer is not independently trust-anchored. UCANs therefore do not yet grant scoped collaborator access.
- Agent lifecycle revocation is not enforced by HTTP Signature authorization; do not rely on removing or revoking an agent record to block a compromised signer.
- Task list/get reads (REST and GraphQL) are repository/task-gated and omit
ucan_token. Slash-formrepo_idrows (mirror-hosted tasks) can appear on list for the delegator or trimmed assignee, butGET /api/v1/tasks/{id}and GraphQLtaskfail closed for every caller because there is no local repository backing to serve. Read visibility is not a blanket data-classification boundary: IPFS-pin and Arweave-anchor listings are not repository-gated; withheld path names can be visible to a root reader; and later visibility changes cannot retract content already announced or externally anchored. - Peer writes are signed by upgraded nodes, but strict signed-peer enforcement is opt-in during rolling upgrades.
- GraphQL mutations require a signed identity (a verified signer bound to the acting DID), are limited to the agent-task queue, and are covered by a CI completeness fence that requires signer acquisition and actor binding on every registered mutation; repo writes are not exposed over GraphQL and remain REST-only.
- Pull-request review comments do not yet have threaded line-level anchors, and merges do not enforce approval requirements.
See:
Quickstart: run a local node
The fastest path is Docker Compose. It starts a node and Postgres.
git clone https://github.com/Twigpine/node.git
cd node
cp .env.example .env
docker compose up -d
Your local node will serve:
| Service | Default |
|---|---|
| HTTP API + git smart-HTTP | http://localhost:7545 |
| libp2p QUIC/UDP | 7546 |
| Postgres | compose-managed |
Verify:
curl http://localhost:7545/health
curl http://localhost:7545/api/v1/stats
Expected health response:
{ "status": "ok" }
Stop it:
docker compose down
Install the CLI
# npm (macOS / Linux)
npm install -g @gitlawb/gl
# Homebrew (macOS / Linux)
brew install gitlawb/tap/gl
# curl (macOS / Linux)
curl -fsSL https://twigpine.com/install.sh | sh
# PowerShell (Windows)
irm https://twigpine.com/install.ps1 | iex
Or build from source:
cargo build --release -p gl -p git-remote-gitlawb -p gitlawb-node
Put these binaries on your PATH:
target/release/gl
target/release/git-remote-gitlawb
target/release/gitlawb-node
Check your setup:
gl doctor
First repo flow
Create an identity:
gl identity new
gl identity show
Register against your local node:
gl register --node http://localhost:7545
Create a repo:
gl repo create my-repo --description "My first Twigpine repo" --node http://localhost:7545
Use the git remote helper:
export GITLAWB_NODE=http://localhost:7545
git clone gitlawb://did:key:z6Mk.../my-repo
For public-network use, make sure GITLAWB_NODE points to the node you want. The helper defaults to localhost for local development.
Full lifecycle against an iCaptcha-enforcing node
Public nodes (e.g. node.twigpine.com) require two things on writes:
- RFC 9421 HTTP Signatures: every write is signed by your identity key.
gland thegit-remote-gitlawbhelper do this automatically. An old/unsigned CLI fails with401 not_an_agent;glwill tell you to upgrade and register. - An iCaptcha proof on the spam-gated writes (repo create, fork, register).
glsolves this for you: on the node's403 icaptcha_proof_requiredit reads thex-icaptcha-url/x-icaptcha-levelhints, requests a challenge, solves it locally (arithmetic / algebra / sequence), and retries the same signed request with thex-icaptcha-proofheader. No manual steps, no env vars.
gl identity new # create did:key identity
gl register --node https://node.twigpine.com # signed + auto-solves iCaptcha
gl repo create memlawb --node https://node.twigpine.com # signed + auto-solves iCaptcha
git push origin2 main # origin2 = gitlawb://<your-did>/memlawb (signed)
git clone gitlawb://<your-did>/memlawb # public read, no proof needed
gl doctor # preflight: identity, node, version, iCaptcha
Notes:
requesterIdis always your DID. The proof'ssubclaim must equal the authenticated signer;gl/helper set this automatically and the node enforcessub == authenticated DID(so a proof minted for another identity is rejected).- Proofs are short-lived (~5 min TTL) and single-use. If one expires between solving and use, the client transparently solves a fresh one and retries.
- What needs what: create / fork / register are signed and iCaptcha-gated;
git pushis signed-only (owner signature is the gate, no per-push challenge); reads (clone / fetch /repo info) need no proof. A non-existent repo returns a clear404, never a placeholder. - API-key iCaptcha deployments: set
GITLAWB_ICAPTCHA_URLto your iCaptcha origin andGITLAWB_ICAPTCHA_API_KEYto its key. The client only talks to anhttpsorigin whose host is allowlisted (that URL or the public default), and sends the bearer token only to your configured origin, never to a URL a node advertises, so a hostile node can't capture the key or redirect the solve.
Fetching an object by CID
gl ipfs list # CIDs this node has pinned
gl ipfs get bafkrei... > object.bin # object bytes on stdout
Objects that were pinned before the node started recording which repo they came
from are found by scanning its repo inventory, and that scan stops at the
per-request ceilings in the Configuration table. A stopped scan
answers 503 with a resume token instead of a false "not found", and gl ipfs get
follows the token automatically: up to 8 resumes after the first request, so at
most 9 calls to the node, waiting between attempts for as long as the node's
Retry-After asks and never longer than 5 seconds.
The whole ladder runs under a 60 second wall-clock deadline. The deadline bounds the search, not the download: each attempt gets the time left on it to produce response headers, and once an object is found its bytes stream outside that deadline. They are not unbounded, though. The client's own 30 second HTTP timeout is a total request timeout, running from the start of a request until its body has finished, so a transfer still going 30 seconds after its request began is cut off. Waits between attempts never run past the deadline either, so a single run spends at most around 90 seconds on the network: the deadline plus the 30 second timeout covering the last attempt. Writing the object out sits outside both bounds, so piping into a reader that stops reading can hold the command open longer than that.
Two node-side brakes end a ladder early and are reported rather than retried around. A 429 is terminal, because the node's rate-limit window is an hour and that wait cannot be honored inside one invocation; a transient overload (a 503 carrying no incomplete-scan code) is retried on the token already held, under the same cap, clamp and deadline. The per-IP fanout brake can also stop a ladder well short of the 9 calls, so automatic resumption is not a guarantee of reaching the object.
When a bound stops the ladder with a usable token in hand, the command prints the token and the invocation that continues from it before exiting nonzero:
resume from where this stopped: gl ipfs get bafkrei... --scan <token>
Run that to carry on from where the scan stopped. Re-running without --scan
restarts at the first row, reproduces the same truncation and spends the node's
per-IP budget again, so the token is the only thing that makes progress. Tokens
are valid for an hour.
Architecture
┌──────────────────────────┐
│ gl CLI / git / AI agents │
└────────────┬─────────────┘
│ signed HTTP writes / git smart-HTTP
↓
┌──────────────────────────┐
│ gitlawb-node │
│ Axum API + git routes │
└────────────┬─────────────┘
│
┌────────┴────────┐
↓ ↓
Postgres Bare git repos
metadata local disk / optional S3
│ │
└────────┬────────┘
↓
libp2p peers
gossip + discovery + sync
↓
optional IPFS / Arweave / Base PoS
Core concepts
| Concept | Meaning |
|---|---|
| DID | A user, agent, or node identity derived from an Ed25519 public key. |
| HTTP Signature | RFC 9421 signature proving control of the DID key for write requests. |
| Ref certificate | Signed record of a ref update. Useful for audit and replication. |
| Ref certificate versioning | The wire format is versioned; new certificates are currently issued as v1. gl cert show <repo> <id> --verify requires --expect-node <did>, and the queried node's self-reported DID is not accepted as a trust anchor. v2 is reserved for a future payload shape that carries the version inside the signed bytes; this client refuses to verify v2+ rather than guessing. |
| UCAN | Delegation token for future capability-based workflows. |
| Peer announce | Node-to-node HTTP announcement of DID + public URL. |
| Gossipsub | libp2p topic for ref-update events. |
| Smart HTTP | Standard git protocol over HTTP for clone/fetch/push. |
API surface
The node exposes both git smart-HTTP routes and JSON APIs.
Common public read routes:
GET /health
GET /
GET /api/v1/stats
GET /api/v1/contracts
GET /api/v1/repos
GET /api/v1/repos/{owner}/{repo}
GET /api/v1/repos/{owner}/{repo}/tree
GET /api/v1/repos/{owner}/{repo}/blob/{path}
GET /api/v1/repos/{owner}/{repo}/issues
GET /api/v1/repos/{owner}/{repo}/pulls
GET /api/v1/peers
GET /{owner}/{repo}/info/refs
POST /{owner}/{repo}/git-upload-pack
Signed write routes include:
POST /api/v1/repos
POST /api/register
POST /api/v1/repos/{owner}/{repo}/fork
POST /api/v1/repos/{owner}/{repo}/issues
POST /api/v1/repos/{owner}/{repo}/pulls
POST /api/v1/repos/{owner}/{repo}/pulls/{number}/merge
POST /api/v1/repos/{owner}/{repo}/hooks
POST /api/v1/bounties/{id}/...
POST /{owner}/{repo}/git-receive-pack
These peer write routes support staged rollout:
POST /api/v1/peers/announce
POST /api/v1/sync/notify
When GITLAWB_REQUIRE_SIGNED_PEER_WRITES=false, unsigned legacy peers are accepted on those two routes, but signed requests are verified when signature headers are present. Staged rollout relaxes who may announce, not who owns a peer row. An unsigned announce may register a previously unseen peer, and it may refresh an existing row whose http_url is unchanged, but changing an existing peer's http_url requires an RFC 9421 signature from that peer's own DID and is refused with 403 otherwise. An unsigned announce can only register a did:key, since that is the only method whose verifying key can be resolved and therefore the only kind of row anyone could ever correct through the signed path; any other DID is refused with 400. Once all live peers upgrade, operators can set:
GITLAWB_REQUIRE_SIGNED_PEER_WRITES=true
POST /api/v1/sync/trigger is not part of the staged rollout: it always requires a signature in both config modes and returns 401 without one, because each call drives an O(peers) outbound fan-out.
The flag is not HTTP-only. It also gates inbound gossip ref-update events on the libp2p mesh, which carry an Ed25519 payload signature rather than RFC 9421 headers because there are no HTTP headers to sign. Set to true, an unsigned gossip event is dropped. Set to false, an unsigned event is accepted with a warning during the rolling-upgrade window, and the write budget it consumes is charged to the forwarding peer rather than to the DID it claims, since an unsigned event's claimed DID is asserted and not proven. A present-but-invalid signature is refused in both modes; that is forgery, not an un-upgraded peer.
Because the flag now spans both transports, the rollout order matters in one direction: upgrade every gossip-publishing peer to a build that signs events before you set this to true. Enabling it while an old publisher is still live drops that publisher's ref-updates on arrival, and the publisher sees no error, because gossip has no response to carry one. Upgrading the HTTP peers alone is not sufficient.
There is a second precondition on gossip ingest that the flag does not control. A publisher's node_did must already exist in the receiving node's peers table, or the event is dropped as an unknown peer DID. This holds in both modes, signed and unsigned alike: a valid signature proves key possession, not membership. Rows reach that table over HTTP, either from GITLAWB_BOOTSTRAP_PEERS when this node contacts a bootstrap peer and records the DID it reports, or from a prior POST /api/v1/peers/announce. So a peer that only ever joined the libp2p mesh, with no HTTP announce and no bootstrap contact in either direction, will have its ref-updates dropped even with a good signature and the flag off. If gossip is silently not landing from a peer you can see on the mesh, check that its DID is in GET /api/v1/peers on the receiving side first.
Configuration
All configuration is via environment variables. See .env.example for the full reference.
Minimum required for a persistent node:
DATABASE_URL=postgresql://gitlawb:changeme@localhost:5432/gitlawb
Important node settings:
| Variable | Purpose |
|---|---|
GITLAWB_HOST / GITLAWB_PORT |
HTTP bind address and port. |
GITLAWB_REPOS_DIR |
Local bare repo storage directory. |
GITLAWB_PUBLIC_URL |
Public HTTP URL announced to peers. |
GITLAWB_P2P_PORT |
libp2p QUIC/UDP port. Use 0 to disable. |
GITLAWB_BOOTSTRAP_PEERS |
Comma-separated HTTP peer URLs. |
GITLAWB_P2P_BOOTSTRAP |
Comma-separated libp2p multiaddrs. |
GITLAWB_P2P_KEY |
Path to the node's persistent libp2p identity key file, which fixes the PeerId across restarts. Must name a key file inside a directory, such as /data/keys/p2p.key or ./keys/p2p.key. Two kinds of problem are handled differently. A value that cannot name a securable key file at all (a bare filename, .. traversal, a trailing /, the filesystem root, or a ~/ path that escapes home) is refused before the node binds, so the process exits. Anything wrong with the storage itself (a symlinked or non-directory parent, an unsafe ancestor or working directory, an unreadable or malformed key) leaves p2p off while HTTP keeps serving; the node logs it at error level as p2p_identity_key_load_failed and sets the gitlawb_p2p_identity_key_load_failed metric to 1. Note that /health still reports healthy in that state, so alert on the log event or the metric rather than on the health check. Correcting the storage and restarting restores the same PeerId. On Unix a new key is created 0600 inside a 0700 directory. A key directory that grants access beyond its owner is tightened to 0700 on start; one that is closed too far is refused with a chmod 700 remedy rather than widened, since a directory locked down on purpose is an operator decision. Rotate the key only when the node logs that the directory was group- or world-writable (or the key file itself was group- or world-readable). An ordinary 0755 directory is tightened too, but others could not read a 0600 key or replace its directory entry. An existing key file is never modified: one with group or other bits set is rejected (chmod 600), and so is one this node cannot read. On other platforms no permissions are enforced. Default ~/.gitlawb/p2p.key; point it at a persistent volume when running in a container. |
GITLAWB_BOOTSTRAP_DISABLE_SEEDS |
Disable embedded seed peers for isolated dev/test networks. |
GITLAWB_REQUIRE_SIGNED_PEER_WRITES |
Require peer writes to prove their DID on every transport: RFC 9421 signatures on the announce/sync routes, and an Ed25519 payload signature on gossip ref-update events. Defaults to false during the staged rollout below. Upgrade every gossip publisher before enabling, or their updates are dropped with no error on their side. |
GITLAWB_ENFORCE_OWNER_PUSH |
Require the authenticated pusher to be the repo owner on git-receive-pack. Defaults to true. A did:key signature is authentication, not authorization (anyone can mint a key and sign), so with this off every signed caller may push to every repository, private ones included. Delegated and CI keys count as non-owners: a UCAN git/push capability is verified but not yet honored for authorization, so they cannot push while this is on. Set false only for a rolling upgrade; see docs/RUN-A-NODE.md. |
GITLAWB_AUTO_SYNC |
Enable automatic sync from known peers. |
GITLAWB_RECONCILIATION_SWEEP |
Enable the hourly durability sweep that re-pins/backstops missing objects (default true; disabled when no IPFS/Pinata backend is configured). Public pin repair runs against any configured backend (IPFS, Pinata, or both). Encrypted recovery repair requires local IPFS (GITLAWB_IPFS_API); Pinata-only nodes reconcile public pins only. |
GITLAWB_MAX_PACK_BYTES |
Max git pack body size for smart-HTTP routes. |
GITLAWB_GIT_SERVICE_TIMEOUT_SECS |
Max seconds a served git upload-pack, receive-pack, or info/refs advertisement may run before it is aborted (504). Default 600. Also bounds the withheld-blob classification walk (on both the upload-pack serve and receive-pack replication paths) and the push-side pin-candidate discovery (rev-list / cat-file), each reaped via process-group teardown at the deadline. On the path-scoped upload-pack path the classification walk and the pack serve share ONE deadline, so this value bounds their combined duration rather than granting each stage a full budget: a walk that consumes it leaves the serve nothing and the clone gets a 504. Serving large path-scoped repos may therefore need a higher value than they did when each stage was budgeted separately. Accepted range is 1 to 3153600000 (100 years), since the node derives deadlines from this value and a larger one cannot be represented. |
GITLAWB_GIT_ACQUIRE_TIMEOUT_SECS |
Max seconds the storage-acquisition phase (Tigris HEAD/GET, push advisory-lock) of a served git op may run before the request is shed with a 503, separate from the git-run timeout. The concurrency permit is released on expiry so a stalled backend cannot pin the pool. Default 30. |
GITLAWB_MAX_CONCURRENT_GIT_OPS |
Max concurrent served git READ ops (upload-pack and its info/refs advertisement) across all callers; over-cap sheds a 503 + Retry-After. Anonymous reads draw from this pool, so pair it with GITLAWB_MAX_CONCURRENT_READS_PER_CALLER. Pushes and the receive-pack advertisement have their own pools, so a read flood cannot shed an authenticated push. Default 128. |
GITLAWB_MAX_CONCURRENT_GIT_PUSHES |
Max concurrent git-receive-pack POST operations, in a pool separate from the read pool. The anon receive-pack info/refs advertisement runs in a third pool of the same size, disjoint from both, so an advertisement flood cannot shed a push either. Two per-source push caps are derived from this value (/8, floor 1) and have no env var of their own. Over-cap sheds a 503 + Retry-After. Default 32. |
GITLAWB_MAX_CONCURRENT_READS_PER_CALLER |
Max concurrent read ops a single caller may hold, so one caller cannot monopolize the read pool. Keyed on the resolved source IP, never the DID, and only as granular as GITLAWB_TRUSTED_PROXY: left unset, a node behind an edge or NAT keys every caller on the edge IP and this collapses to one global cap. Default 16. |
GITLAWB_MAX_CONCURRENT_PIN_TASKS |
Max post-push pin loops (IPFS + Pinata) running concurrently across all repos. This caps how many loops RUN at once, not how much object-id list memory the node retains: on the local IPFS path a loop parked waiting for a permit still holds its full list. Do not size memory from this knob alone. A loop over cap waits, never drops a pin. Default 8. |
GITLAWB_REPO_LEASE_MAX_WAITERS |
Max pushes parked at once waiting for the same repo's write lease. Each waiter pins its buffered pack body, so this bounds that memory for a hot repo; past the cap the newest push sheds a 503 + Retry-After instead of queueing. Pushes to other repos are unaffected, and the lease holder is not counted. Default 8. |
GITLAWB_MAX_CONCURRENT_IPFS_WALKS |
Max concurrent GET /ipfs/{cid} visibility walks across all callers (own pool, disjoint from the served-git pools); over-cap sheds 503. Default 32. |
GITLAWB_IPFS_WALK_PER_SOURCE |
Max concurrent /ipfs walks a single source IP may hold. Default 4. |
GITLAWB_IPFS_MAX_LEGACY_PROBES |
Max legacy (NULL-provenance) repos probed per /ipfs/{cid} request, bounding the scan-fallback fan-out. A truncated scan returns a retryable 503, not a false 404. Default 256. |
GITLAWB_IPFS_MAX_LEGACY_SCAN_ROWS |
Max repo rows one /ipfs/{cid} request's legacy scan may read from the database. The probe ceiling above only starts counting once a probe runs, and quarantined or private repos are denied before that, so this is what bounds a scan over an inventory that denies the caller everywhere. A truncated scan sheds a retryable 503 carrying an opaque continuation token; echoing it as ?scan= resumes the scan where it stopped. Every per-request ceiling on this path (rows, probes, visits, retained rule bytes) mints one, so each request advances the ladder by at least the rows it read and a holder buried past a ceiling is reached in a bounded number of requests, ceil(repos / ceiling) + 1 when this row ceiling is the one that binds. No ceiling ever produces a 404. Every page is charged to the caller's /ipfs work allowance, so raising this raises that allowance too. Lowering it sharpens an oracle: because a truncation emits a token and a completed wrap does not, laddering to the end reveals the node's total repo count (private and quarantined included) to within one ceiling. Default 2048. |
GITLAWB_IPFS_MAX_REPOS_WALKED |
Max expensive path-scope visibility walks a /ipfs/{cid} request may run per phase; over-cap repos are skipped and the scan continues, shedding a retryable 503 (not a false 404) if the object is then found nowhere. The effective cap is the tighter of this knob and the node's internal history-walk ceiling of 17 (MAX_PIN_SOURCES + 1), so a value above 17 has no effect while a value below it does tighten the cap. It is charged per phase: the provenance lookup and the legacy-scan fallback get separate equal budgets, so one request can run up to twice the cap in total. Default 64. |
GITLAWB_IPFS_MAX_REPO_VISITS |
Ceiling on repos one /ipfs/{cid} request may visit (acquire + probe) past the visibility gate. Also the worst-case per-request Tigris fetch count. On exhaustion the scan stops with a retryable 503. Default 1024. |
GITLAWB_IPFS_REQUEST_BUDGET_SECS |
Absolute wall-clock budget for one admitted /ipfs/{cid} request's acquire+walk lifetime. Per-stage clamps bound the acquire and walk stages to the remaining budget, and no stage starts once it is exhausted; the scan then stops with a retryable 503. The object-type probe and content-read cat-file subprocesses are budget-checked before starting and each also run under their own deadline (the lesser of GITLAWB_GIT_SERVICE_TIMEOUT_SECS and the remaining budget), reaped via process-group teardown, so a hung cat-file cannot hold the request's walk slot past it. One hang path is still unbounded: the probe's object-store readability check is a plain filesystem sweep with nothing to reap, so a wedged filesystem can hold the slot past the deadline. Default 600. Accepted range is 1 to 3153600000 (100 years), since the node derives a deadline from this value and a larger one cannot be represented. |
GITLAWB_IPFS_RESOLVE_BUDGET_SECS |
Shorter budget for the pre-walk CID resolve inside an admitted /ipfs/{cid} request: the lookup that maps the requested CID to its git oid(s), which runs while the scarce walk admission is already held. A well-formed CID with no pin row does no probe and no walk work, so without this it could hold a walk slot for the whole request budget while nothing walked, and enough such requests shed every real retrieval at admission. The effective deadline is the lesser of this and the remaining request budget, so a value above GITLAWB_IPFS_REQUEST_BUDGET_SECS degrades to the request budget. Only the resolve is on this clock; walk and probe work stay on the request budget, so a slow but progressing scan is never shed by it. Default 10. Accepted range is 1 to 3153600000 (100 years). |
GITLAWB_IPFS_RATE_LIMIT |
Max /ipfs/{cid} requests per client IP per hour (route flood brake). 0 disables. Default 600. |
GITLAWB_TASK_READ_RATE_LIMIT |
Max anonymous task-read requests (GET /api/v1/tasks, GET /api/v1/tasks/{id}) per client IP per hour, sharing the budget with the GraphQL/WS task-read brake. Uses GITLAWB_TRUSTED_PROXY to resolve the client IP. 0 disables. Default 1200 (above the /ipfs budget to allow list pages followed by per-task reads). |
GITLAWB_TIGRIS_BUCKET |
Optional S3/Tigris shared repo storage bucket. |
GITLAWB_PINATA_JWT |
Optional Pinata/IPFS warm-storage pinning. |
GITLAWB_IRYS_URL |
Optional Irys/Arweave permanent anchoring. |
Production note: change the default Postgres password before exposing a node publicly.
Legacy-pin window: releases before the CID-resolver work stored the provider CID (Kubo dag-pb / Pinata) as a pinned object's resolver key. The /ipfs/{cid} resolver now recomputes the raw-content CID from the object bytes and refuses to serve a key that does not match, so GET /api/v1/ipfs/pins can still advertise an unrepaired legacy CID that 404s. Such a row is repaired opportunistically the next time a push carries the object again (its key is rewritten to the raw CID, the old value kept in legacy_provider_cid), but git negotiation omits objects the node already has, so most legacy rows never re-enter a push delta. A deferred one-shot startup sweep, not this opportunistic path, is what fully retires the advertise-then-404 window. Rows whose object bytes are gone stay withheld.
Upgrading: the PeerId rotates once
This node's libp2p identity is now a keypair generated on first start and kept
at GITLAWB_P2P_KEY, rather than one derived from the node DID. Every node
therefore gets a new PeerId once, on the first start after upgrading, and keeps
it from then on as long as that key file survives (put it on a persistent volume
in a container).
Two things to check before upgrading:
- Any
GITLAWB_P2P_BOOTSTRAPmultiaddr that pins a peer's old PeerId with a/p2p/<PeerId>suffix stops matching once that peer upgrades. Update the suffix, or drop it and let identify supply the current one. Addresses without the suffix keep working untouched. GITLAWB_P2P_KEYmust name a key file inside a directory, such as/data/keys/p2p.keyor./keys/p2p.key. A bare filename is refused at startup, since the node will not keep its identity key in the working directory.
Peers found over GITLAWB_BOOTSTRAP_PEERS and the embedded seed list are
unaffected, since those are HTTP URLs and carry no PeerId.
Optional node staking
Twigpine Node includes optional Base L2 node-operator hooks. Operators can register a node DID, stake $GITLAWB, and post heartbeats.
PoS is disabled unless these are configured:
GITLAWB_CONTRACT_NODE_STAKING=0x...
GITLAWB_OPERATOR_PRIVATE_KEY=0x...
GITLAWB_CHAIN_RPC_URL=https://mainnet.base.org
Recommended for operators:
GITLAWB_OPERATOR_STRICT_MODE=true
GITLAWB_HEARTBEAT_INTERVAL_HOURS=20
Read:
Use a dedicated low-balance operator wallet. Do not use a treasury wallet as the heartbeat key.
Building from source
Requires Rust 1.91+.
cargo build --release -p gitlawb-node -p gl -p git-remote-gitlawb
Run tests:
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
Run the node from source:
DATABASE_URL=postgresql://gitlawb:changeme@localhost:5432/gitlawb \
cargo run -p gitlawb-node --release
macOS menu bar app
A native Swift/AppKit menu bar app is included for managing a local Docker Compose stack without living in the terminal.
Requirements:
- macOS 26+
- Xcode Command Line Tools
- Docker Desktop, OrbStack, or Colima
Build:
./scripts/build-macos-app.sh
Output:
dist/Twigpine Node.app
dist/GitlawbNode-macOS.dmg
Features:
- Start/stop local node stack.
- Status indicator.
- Settings GUI.
- Auto-start on login.
- Docker runtime detection.
Unsigned local build:
xattr -cr "dist/Twigpine Node.app"
Roadmap
The current maintainer focus is live-network stability first.
Short-term priorities:
- Keep CI green: fmt, clippy, tests, release build.
- Add Docker and installer smoke tests.
- Improve operator docs and
gl doctorchecks. - Harden peer writes and publish the signed-peer rollout plan.
- Wire trusted UCAN delegation into repository write authorization.
- Add threaded, line-level pull-request discussions and enforce approval requirements on merges.
- Add metrics for pushes, fetches, pack sizes, peer sync, failed auth, and webhooks.
Product direction:
- Reliable repo replication.
- Health-aware peer syncing.
- CDN-style clone/fetch routing to healthy replicas.
- App asset/build delivery from nodes.
- Operator dashboard and desktop UX.
Read the maintainer roadmap:
docs/MAINTAINER-ROADMAP.md
Contributing
Start here:
Good first contribution areas:
- docs and install polish
- Docker smoke tests
- CLI error messages
gl doctorchecks- operator dashboard UX
- test coverage for peer sync and signed writes
Security issues should follow SECURITY.md.
License
Licensed under either of:
- Apache License, Version 2.0 (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0)
- MIT license (LICENSE-MIT or http://opensource.org/licenses/MIT)
at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this project shall be dual licensed as above, without any additional terms or conditions.