gitshark

Clone repository

git clone https://gitshark.de/git/workaround/Gitshark.git
git clone git@gitshark.de:workaround/Gitshark.git

← Commits

πŸ“¦ (openspec): Archive add-forgefed-federation and sync federation specs

9f1b4c961eab38719871852abbd4c5b72aea1ed6 Β· Miggi Β· 2026-06-26T14:08:18Z

Changes

20 files changed, +921 -601

DELETE openspec/changes/add-forgefed-federation/.openspec.yaml +0 -2
diff --git a/openspec/changes/add-forgefed-federation/.openspec.yaml b/openspec/changes/add-forgefed-federation/.openspec.yaml
deleted file mode 100644
index 6c351ac..0000000
--- a/openspec/changes/add-forgefed-federation/.openspec.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
1 -schema: spec-driven
2 -created: 2026-06-21
DELETE openspec/changes/add-forgefed-federation/design.md +0 -130
diff --git a/openspec/changes/add-forgefed-federation/design.md b/openspec/changes/add-forgefed-federation/design.md
deleted file mode 100644
index 2adabf1..0000000
--- a/openspec/changes/add-forgefed-federation/design.md
+++ /dev/null
@@ -1,130 +0,0 @@
1 -## Context
2 -
3 -git-shark is a single Quarkus 21 service (JGit + Apache MINA SSHD, PostgreSQL via Flyway, OIDC login,
4 -Qute UI) that compiles to a GraalVM native image. Entities today are `User`, `Repository`, `SshKey`,
5 -`AccessToken` (see `V1__init.sql`); Git access goes through `GitHttpServlet` (smart HTTP) and
6 -`SshGitBridge` (SSH). There is no issue, PR, or notification model.
7 -
8 -ForgeFed is an ActivityPub extension. ActivityPub server-to-server ("S2S") requires: actors with
9 -`inbox`/`outbox`/`followers` collections published as JSON-LD; actor discovery (WebFinger + content
10 -negotiation); authenticated delivery via **HTTP Signatures** (each actor has a keypair, the public
11 -key is published in the actor document, requests are signed with the private key and verified by the
12 -receiver fetching the sender's key). ForgeFed adds forge vocabulary: the `Repository` actor and the
13 -`Push` activity are the two we need for the first goal.
14 -
15 -Constraints: must stay native-image-safe (no runtime-classpath JSON-LD reflection magic, manual
16 -reflection registration for new model classes), reuse the already-present BouncyCastle for crypto,
17 -and not regress local Git access or OIDC. Actor IDs are absolute URLs and must never change once
18 -published, so a configured **public base URL** is foundational.
19 -
20 -## Goals / Non-Goals
21 -
22 -**Goals:**
23 -- Two git-shark instances federate: A follows a repo on B and receives B's `Push` activities.
24 -- Standards-compliant ActivityStreams + ForgeFed JSON-LD and HTTP Signatures, so non-git-shark
25 - ForgeFed software *could* interoperate later.
26 -- All federation is optional and off by default; bounded by a peer allowlist on first rollout.
27 -- Native-image compatible; no new heavyweight dependencies if avoidable.
28 -
29 -**Non-Goals:**
30 -- Tickets/issues/merge-requests federation (no local feature to federate yet).
31 -- Verified interop with Forgejo/Vervis/Mastodon (kept possible, not tested).
32 -- Cross-instance push/write access or remote-commit mirroring; following is read-only awareness.
33 -- Rich timeline/notification UI.
34 -
35 -## Decisions
36 -
37 -**Decision: A dedicated `/ap` route namespace for actor documents, with content negotiation on
38 -existing pages.** Actor IDs are `https://{host}/ap/repos/{owner}/{name}` (Repository),
39 -`https://{host}/ap/users/{username}` (Person), and `https://{host}/ap/instance` (the instance
40 -Application actor). `GET /repos/{owner}/{name}` with `Accept: application/activity+json` 303-redirects
41 -(or directly serves) the actor document, while HTML stays the default. Rationale: keeps the JSON-LD
42 -representation cleanly separable and cache-controllable, avoids overloading the HTML controllers with
43 -serialization branching, and gives a stable, greppable federation surface. Alternative β€” serve
44 -JSON-LD inline from `RepositoryResource` by sniffing `Accept` β€” rejected: muddies the UI controller
45 -and complicates native reflection scoping.
46 -
47 -**Decision: Per-actor RSA-2048 keypairs, generated lazily and stored in Postgres.** Each Repository,
48 -each federating User, and the instance actor get an RSA keypair (RSA chosen for maximum ActivityPub
49 -interop; Ed25519 is patchy across the fediverse). Keys are generated on first federation use and
50 -cached. Rationale: matches Mastodon/ForgeFed convention so signatures verify everywhere; BouncyCastle
51 -(already a dependency) does the crypto. Alternative β€” one instance-wide key signing on behalf of all
52 -actors β€” rejected: breaks the actor model and many receivers reject key/actor mismatch.
53 -
54 -**Decision: HTTP Signatures (draft-cavage, `rsa-sha256`) implemented in-house over BouncyCastle/JCA.**
55 -Sign outbound POSTs over `(request-target) host date digest` (+ `content-type`); verify inbound by
56 -parsing the `Signature` header, fetching the named `keyId` actor, and checking the digest + signature.
57 -Rationale: it is a small, well-specified algorithm; no maintained native-image-friendly Java library
58 -is worth a new dependency. Alternative β€” pull in a signing library β€” rejected for native-image risk
59 -and dependency weight.
60 -
61 -**Decision: JSON-LD by hand-built Jackson trees against a pinned `@context`, not a JSON-LD processor.**
62 -We emit/consume compacted documents with a fixed `@context` (`https://www.w3.org/ns/activitystreams`,
63 -`https://w3id.org/security/v1`, `https://forgefed.org/ns`). Serialization uses plain POJOs/`ObjectNode`
64 -via the existing Jackson; we do **not** do generic JSON-LD expansion/normalization. Rationale: full
65 -JSON-LD processing is heavy and native-image-hostile, and S2S interop in practice relies on the
66 -agreed compacted shape. Alternative β€” Titanium/jsonld-java β€” rejected: size, reflection, and
67 -overkill for fixed vocabularies.
68 -
69 -**Decision: Persisted outbound delivery queue drained by a Quarkus scheduled worker.** Outgoing
70 -activities are written to a `federation_delivery` table (target inbox, payload, attempts,
71 -next_attempt_at, state) inside the triggering transaction, then a `@Scheduled` worker POSTs them with
72 -HTTP-Signature auth and exponential backoff, marking delivered/failed. Rationale: delivery must
73 -survive restarts and tolerate offline peers; doing it inline on the push path would couple Git
74 -latency to remote availability. Alternative β€” fire-and-forget async β€” rejected: lost activities on
75 -crash/peer downtime, no retry.
76 -
77 -**Decision: Push activity is triggered by a post-receive signal from both transports.** `GitHttpServlet`
78 -and `SshGitBridge` already mediate every push; both invoke a single `FederationPushService.onPush(repo,
79 -refUpdates)` after a successful receive-pack. It builds a ForgeFed `Push` object (ref, old/new SHAs,
80 -the new commits) attributed to the pusher's Person actor, appends it to the repo `outbox`, and
81 -enqueues delivery to each follower inbox. Rationale: one choke point, both transports covered, no
82 -reliance on on-disk Git hooks (native-image and packaging friendly). Alternative β€” JGit
83 -`PostReceiveHook` scripts on disk β€” rejected: fragile under native packaging and container layout.
84 -
85 -**Decision: Inbound activities are deduplicated and signature-gated before processing.** Every inbox
86 -POST must carry a valid HTTP Signature from an allowlisted peer; the activity `id` is recorded in
87 -`federation_inbox` for idempotency; only `Follow`, `Undo`(`Follow`), and `Accept` are processed in
88 -this change (others are stored/ignored). Rationale: idempotent, secure inbox is the backbone of S2S.
89 -Alternative β€” process before dedup β€” rejected: replay and double-processing.
90 -
91 -**Decision: Federation is opt-in and allowlist-bounded.** `gitshark.federation.enabled` (default
92 -false), `gitshark.federation.base-url`, and `gitshark.federation.peer-allowlist` (host list; empty =
93 -deny all remote). Repositories federate only when `PUBLIC`. Rationale: safe, incremental rollout for
94 -the git-shark↔git-shark first goal; the allowlist can later be relaxed to open federation.
95 -
96 -## Risks / Trade-offs
97 -
98 -- **[Hand-rolled HTTP Signatures are easy to get subtly wrong β†’ interop/security bugs]** β†’ narrow,
99 - well-tested implementation against the draft-cavage signing string; round-trip tests A↔B; reject on
100 - any verification failure (fail closed).
101 -- **[SSRF / malicious actor URLs when fetching remote keys]** β†’ fetch only over HTTPS to hosts on the
102 - peer allowlist, block private/loopback/link-local IPs, cap response size and redirects.
103 -- **[Native image breakage from new Jackson model + HTTP client]** β†’ register reflection for the
104 - federation DTOs and add the client to native config; keep an integration test that runs the native
105 - binary path in CI (existing native profile).
106 -- **[Actor IDs become permanent the moment they federate]** β†’ require an explicit, validated
107 - `base-url`; refuse to emit actor documents if it is unset/localhost while `enabled=true`.
108 -- **[Delivery worker storms a downed peer]** β†’ exponential backoff with a max attempt cap and a
109 - dead-letter state; per-host serialization.
110 -- **[Key compromise]** β†’ keys are per-actor and rotatable (regenerate + republish); document the
111 - rotation path even if automation is later.
112 -
113 -## Migration Plan
114 -
115 -Additive. One new Flyway migration (`V2__federation.sql`) creates `federation_keys`,
116 -`remote_actors`, `repository_followers`, `federation_inbox`, `federation_delivery`. No existing table
117 -changes. Feature ships **disabled** (`gitshark.federation.enabled=false`); enabling it on an instance
118 -with a configured `base-url` + allowlist is the activation step. Rollback = disable the flag (actors
119 -stop being served, queue drains/stops); the migration is forward-only but inert when disabled.
120 -
121 -## Open Questions
122 -
123 -- Do Users federate as `Person` actors in this first cut, or only Repositories (with pushes attributed
124 - to a bare actor URI)? Leaning: minimal `Person` actor so `Push.attributedTo` resolves.
125 -- Actor document delivery for `GET /repos/...` with `application/activity+json`: 303-redirect to `/ap/...`
126 - vs. serve inline. Leaning redirect for clean separation.
127 -- Followers collection visibility: public vs. authenticated-only. Leaning public (read), matching most
128 - fediverse software.
129 -- Do we publish `nodeinfo` (`/.well-known/nodeinfo`) now for peer capability discovery, or defer? Likely
130 - defer to a follow-up unless a peer needs it.
DELETE openspec/changes/add-forgefed-federation/proposal.md +0 -83
diff --git a/openspec/changes/add-forgefed-federation/proposal.md b/openspec/changes/add-forgefed-federation/proposal.md
deleted file mode 100644
index 61855e9..0000000
--- a/openspec/changes/add-forgefed-federation/proposal.md
+++ /dev/null
@@ -1,83 +0,0 @@
1 -## Why
2 -
3 -git-shark today is an island: every instance hosts its own repositories, users, and access, with no
4 -way for someone on one instance to follow, watch, or interact with a repository on another. We want
5 -**federation** so that two git-shark instances can talk to each other β€” a user on instance A can
6 -follow a repository on instance B and see its activity (pushes), without an account on B.
7 -
8 -We adopt [ForgeFed](https://forgefed.org) (an ActivityPub extension for software forges) rather than
9 -inventing a private protocol. The immediate, concrete goal is **git-shark ↔ git-shark**, but by
10 -speaking standard ActivityStreams + ForgeFed vocabulary over ActivityPub server-to-server, we stay
11 -open to interoperating with other ActivityPub/ForgeFed software (Forgejo, etc.) later β€” that
12 -interop is explicitly low priority now, but must not be designed out.
13 -
14 -## What Changes
15 -
16 -- Expose git-shark Repositories (and Users) as **ForgeFed/ActivityPub actors** with stable IDs,
17 - signing keys, and the required `inbox` / `outbox` / `followers` collections, discoverable via
18 - content negotiation (`application/activity+json`) and **WebFinger**.
19 -- Implement **server-to-server transport**: sign outgoing requests with HTTP Signatures, verify
20 - incoming signatures by fetching+caching the remote actor's public key, accept activities at
21 - actor `inbox` endpoints, and deliver outgoing activities through a persisted, retrying queue.
22 -- Implement the **Follow flow**: a remote actor sends `Follow` β†’ repository actor responds `Accept`,
23 - is added to the repository's `followers`; `Undo`(`Follow`) removes it.
24 -- Implement the **Push announce flow**: when commits are pushed to a federated repository, publish a
25 - ForgeFed `Push` activity to the repository's `outbox` and deliver it to all followers, so a remote
26 - instance learns about new commits.
27 -- Add a per-instance **federation configuration**: an enable switch, the instance's public base URL
28 - (actor IDs are absolute and must be stable), and an instance-peering **allowlist** so the first
29 - rollout can be limited to trusted git-shark peers.
30 -
31 -## Capabilities
32 -
33 -### New Capabilities
34 -
35 -- `federation-actors`: Repository and User exposed as ActivityPub/ForgeFed actors β€” JSON-LD actor
36 - documents, per-actor RSA signing keypairs, `inbox`/`outbox`/`followers` collections, an
37 - instance-level application actor, and WebFinger (`/.well-known/webfinger`) discovery via content
38 - negotiation, all native-image compatible.
39 -- `federation-transport`: ActivityPub server-to-server delivery β€” HTTP Signature signing of outbound
40 - requests and verification of inbound ones, remote actor/key fetching with caching and SSRF
41 - guards, inbox receipt + validation + deduplication, and a persisted outbound delivery queue with
42 - retry/backoff.
43 -- `federation-following`: Follow / Accept / Undo(Follow) handshake between a repository actor and a
44 - remote actor; the repository's `followers` collection is the delivery list for its activities.
45 -- `federation-push-announce`: On push to a federated repository, build and persist a ForgeFed `Push`
46 - activity, place it in the repository `outbox`, and fan it out to followers via the delivery queue.
47 -
48 -### Modified Capabilities
49 -
50 -<!-- No archived main specs exist yet (create-git-platform is not archived), so federation is added
51 - as new capabilities. The git push hook integration (git-smart-http / git-ssh-access) and the
52 - repository actor route (web-ui content negotiation) extend pending capabilities; those touch
53 - points are captured inside this change's specs rather than as separate delta specs. -->
54 -
55 -## Impact
56 -
57 -- **New code** under `de.workaround.federation.*`: actor document model + serializers, WebFinger
58 - endpoint, HTTP Signature sign/verify, an ActivityPub client (remote actor fetch + inbox POST),
59 - inbox/outbox JAX-RS resources, a delivery worker, and the Push/Follow/Accept activity builders.
60 -- **Database (new Flyway migration)**: actor signing keys, remote-actor cache, repository followers,
61 - received-activity log (dedup), and the outbound delivery queue.
62 -- **Git transport hooks**: the smart-HTTP (`GitHttpServlet`) and SSH (`SshGitBridge`) push paths must
63 - emit a post-receive signal that triggers the `Push` activity. Content negotiation on
64 - `GET /repos/{owner}/{name}` (and user pages) returns the actor JSON-LD for `application/activity+json`.
65 -- **Config**: new `gitshark.federation.*` properties (enabled flag, public base URL, peer allowlist);
66 - an HTTP client for outbound federation; scheduled delivery worker. Actor IDs depend on a correct,
67 - stable public base URL.
68 -- **Native image**: new Jackson-serialized JSON-LD model classes and the federation HTTP client need
69 - reflection/resource registration alongside the existing JGit/MINA config; crypto reuses the
70 - already-present BouncyCastle.
71 -- **Security**: signature verification is mandatory on inbound activities; remote fetches are
72 - SSRF-guarded; the peer allowlist bounds the initial blast radius. No change to OIDC login or local
73 - Git access control.
74 -
75 -## Non-Goals
76 -
77 -- Federating issues/tickets or merge/pull requests β€” git-shark has no issue or PR features yet, so
78 - ForgeFed `Offer`(`Ticket`) / patch flows are out of scope until those exist.
79 -- Interop testing against non-git-shark software (Forgejo, Vervis, Mastodon). We stay
80 - standards-compliant so it remains possible, but it is not validated here.
81 -- Federated identity/login, cross-instance authorization to push, or pulling remote commits into a
82 - local mirror. Following is read-only awareness of activity, not write access.
83 -- Inbox UI/timeline beyond what is needed to prove delivery; rich notification UX is later.
DELETE openspec/changes/add-forgefed-federation/specs/federation-actors/spec.md +0 -101
diff --git a/openspec/changes/add-forgefed-federation/specs/federation-actors/spec.md b/openspec/changes/add-forgefed-federation/specs/federation-actors/spec.md
deleted file mode 100644
index 781c4a4..0000000
--- a/openspec/changes/add-forgefed-federation/specs/federation-actors/spec.md
+++ /dev/null
@@ -1,101 +0,0 @@
1 -## ADDED Requirements
2 -
3 -### Requirement: Repository is published as a ForgeFed Repository actor
4 -
5 -The system SHALL expose every `PUBLIC` repository as a ForgeFed `Repository` actor with a stable,
6 -absolute `id` of the form `https://{base-url}/ap/repos/{owner}/{name}`. The actor document SHALL be
7 -valid JSON-LD with `@context` including `https://www.w3.org/ns/activitystreams`,
8 -`https://w3id.org/security/v1`, and `https://forgefed.org/ns`, and SHALL include `inbox`, `outbox`,
9 -`followers`, `preferredUsername`, `name`, and a `publicKey` with the actor's PEM-encoded public key.
10 -
11 -#### Scenario: Repository actor document is served via content negotiation
12 -
13 -- **WHEN** a client requests the repository with `Accept: application/activity+json`
14 -- **THEN** the system returns the JSON-LD `Repository` actor document with `Content-Type:
15 - application/activity+json` and `type` `Repository`
16 -
17 -#### Scenario: HTML stays the default representation
18 -
19 -- **WHEN** a browser requests `GET /repos/{owner}/{name}` with an HTML `Accept` header
20 -- **THEN** the system returns the existing HTML repository page, not the actor document
21 -
22 -#### Scenario: Private repositories are not federated
23 -
24 -- **WHEN** a client requests an actor document for a `PRIVATE` repository
25 -- **THEN** the system responds `404` and does not expose any actor document
26 -
27 -### Requirement: Users are published as Person actors
28 -
29 -The system SHALL expose a federating user as an ActivityPub `Person` actor with `id`
30 -`https://{base-url}/ap/users/{username}`, an `inbox`, an `outbox`, and a `publicKey`, so that
31 -activities attributed to that user (e.g. a push) reference a resolvable actor.
32 -
33 -#### Scenario: Person actor resolves
34 -
35 -- **WHEN** a client requests `GET /ap/users/{username}` with `Accept: application/activity+json`
36 -- **THEN** the system returns a `Person` actor document with a resolvable `inbox` and `publicKey`
37 -
38 -### Requirement: An instance application actor exists
39 -
40 -The system SHALL expose a single instance-level `Application` actor at
41 -`https://{base-url}/ap/instance` with its own keypair, used to sign instance-level requests such as
42 -fetching remote actors.
43 -
44 -#### Scenario: Instance actor is available for signing
45 -
46 -- **WHEN** the system needs to fetch a remote actor document
47 -- **THEN** it signs the request as the instance `Application` actor whose `publicKey` is resolvable
48 - at `/ap/instance`
49 -
50 -### Requirement: Each actor has a persistent signing keypair
51 -
52 -The system SHALL generate an RSA-2048 keypair per federating actor (repository, user, instance) on
53 -first federation use and persist it, so the published `publicKey` is stable and the private key is
54 -reused across restarts.
55 -
56 -#### Scenario: Key is generated once and reused
57 -
58 -- **WHEN** an actor federates for the first time
59 -- **THEN** the system generates and stores a keypair
60 -- **AND WHEN** the same actor federates again after a restart
61 -- **THEN** the system reuses the stored keypair and publishes the same `publicKey`
62 -
63 -### Requirement: Actors expose inbox, outbox, and followers collections
64 -
65 -The system SHALL serve, for each actor, an `inbox` (POST endpoint for incoming activities), an
66 -`outbox` (ordered collection of the actor's published activities), and a `followers` ordered
67 -collection, each addressable at the URL named in the actor document.
68 -
69 -#### Scenario: Collections are reachable
70 -
71 -- **WHEN** a client requests an actor's `outbox` or `followers` URL with `Accept:
72 - application/activity+json`
73 -- **THEN** the system returns an `OrderedCollection` JSON-LD document
74 -
75 -### Requirement: Actors are discoverable via WebFinger
76 -
77 -The system SHALL respond to `GET /.well-known/webfinger?resource=acct:{name}@{host}` for repository
78 -and user actors, returning a JRD document whose `links` include a `self` link of type
79 -`application/activity+json` pointing to the actor `id`.
80 -
81 -#### Scenario: WebFinger resolves a repository actor
82 -
83 -- **WHEN** a client requests `GET /.well-known/webfinger?resource=acct:{owner}/{name}@{host}` for a
84 - public repository
85 -- **THEN** the system returns a `200` JRD with a `self` link to the repository actor `id`
86 -
87 -#### Scenario: WebFinger rejects unknown subjects
88 -
89 -- **WHEN** a WebFinger request names a subject that does not exist or is private
90 -- **THEN** the system responds `404`
91 -
92 -### Requirement: Federation requires a configured public base URL
93 -
94 -When `gitshark.federation.enabled` is true, the system SHALL require a valid, absolute, non-loopback
95 -`gitshark.federation.base-url`, and SHALL refuse to emit actor documents (failing closed) if it is
96 -unset or points at localhost, because actor IDs are permanent once published.
97 -
98 -#### Scenario: Missing base URL blocks actor emission
99 -
100 -- **WHEN** federation is enabled but `base-url` is unset or a loopback address
101 -- **THEN** the system does not serve actor documents and surfaces a configuration error
DELETE openspec/changes/add-forgefed-federation/specs/federation-following/spec.md +0 -50
diff --git a/openspec/changes/add-forgefed-federation/specs/federation-following/spec.md b/openspec/changes/add-forgefed-federation/specs/federation-following/spec.md
deleted file mode 100644
index c4d2d93..0000000
--- a/openspec/changes/add-forgefed-federation/specs/federation-following/spec.md
+++ /dev/null
@@ -1,50 +0,0 @@
1 -## ADDED Requirements
2 -
3 -### Requirement: A remote actor can follow a repository
4 -
5 -The system SHALL accept a `Follow` activity addressed to a repository actor's inbox, whose `object`
6 -is the repository actor `id`, from an allowlisted, signature-verified remote actor, and SHALL add
7 -that actor to the repository's `followers` collection.
8 -
9 -#### Scenario: Follow is accepted and recorded
10 -
11 -- **WHEN** a verified remote actor POSTs a `Follow` of a public repository to its inbox
12 -- **THEN** the system records the remote actor in the repository's `followers` collection
13 -
14 -#### Scenario: Follow of a private or unknown repository is refused
15 -
16 -- **WHEN** a `Follow` targets a private or non-existent repository
17 -- **THEN** the system does not add a follower and does not send an `Accept`
18 -
19 -### Requirement: The repository responds with Accept
20 -
21 -After recording a follower, the system SHALL deliver an `Accept` activity, whose `object` is the
22 -original `Follow`, from the repository actor to the follower's inbox.
23 -
24 -#### Scenario: Accept is delivered to the follower
25 -
26 -- **WHEN** a `Follow` has been accepted and the follower recorded
27 -- **THEN** the system enqueues an `Accept` activity for delivery to the follower's inbox referencing
28 - the original `Follow`
29 -
30 -### Requirement: A follower can undo its follow
31 -
32 -The system SHALL accept an `Undo` activity whose `object` is a previously accepted `Follow` and SHALL
33 -remove the actor from the repository's `followers` collection, stopping further activity delivery to
34 -it.
35 -
36 -#### Scenario: Undo removes the follower
37 -
38 -- **WHEN** a current follower POSTs a verified `Undo` of its earlier `Follow`
39 -- **THEN** the system removes that actor from the `followers` collection
40 -- **AND** subsequent repository activities are no longer delivered to it
41 -
42 -### Requirement: The followers collection is the delivery audience
43 -
44 -The system SHALL treat the repository's `followers` collection as the set of inboxes to which the
45 -repository's published activities are delivered.
46 -
47 -#### Scenario: Followers receive repository activities
48 -
49 -- **WHEN** the repository publishes an activity to its outbox
50 -- **THEN** the system enqueues delivery to the inbox of every actor in its `followers` collection
DELETE openspec/changes/add-forgefed-federation/specs/federation-push-announce/spec.md +0 -49
diff --git a/openspec/changes/add-forgefed-federation/specs/federation-push-announce/spec.md b/openspec/changes/add-forgefed-federation/specs/federation-push-announce/spec.md
deleted file mode 100644
index a6a682b..0000000
--- a/openspec/changes/add-forgefed-federation/specs/federation-push-announce/spec.md
+++ /dev/null
@@ -1,49 +0,0 @@
1 -## ADDED Requirements
2 -
3 -### Requirement: A successful push to a federated repository emits a Push activity
4 -
5 -When commits are received on a `PUBLIC` repository over either Git transport (smart HTTP or SSH) and
6 -federation is enabled, the system SHALL build a ForgeFed `Push` activity describing the updated ref,
7 -the previous and new commit ids, and the newly received commits, attributed to the pusher's Person
8 -actor.
9 -
10 -#### Scenario: Push over HTTP emits a Push activity
11 -
12 -- **WHEN** a user pushes new commits to a public repository over smart HTTP
13 -- **THEN** the system builds a `Push` activity referencing the repository, the updated ref, the
14 - old/new commit SHAs, and is attributed to the pusher
15 -
16 -#### Scenario: Push over SSH emits a Push activity
17 -
18 -- **WHEN** a user pushes new commits to a public repository over SSH
19 -- **THEN** the system builds an equivalent `Push` activity through the same code path
20 -
21 -#### Scenario: Push to a private repository emits nothing
22 -
23 -- **WHEN** a push targets a `PRIVATE` repository, or federation is disabled
24 -- **THEN** the system does not build or deliver any activity
25 -
26 -### Requirement: Push activities are added to the repository outbox
27 -
28 -The system SHALL append each emitted `Push` activity to the repository actor's `outbox` ordered
29 -collection with a stable activity `id`.
30 -
31 -#### Scenario: Push appears in the outbox
32 -
33 -- **WHEN** a `Push` activity has been emitted
34 -- **THEN** it is present in the repository's `outbox` collection with a resolvable activity `id`
35 -
36 -### Requirement: Push activities are delivered to followers
37 -
38 -The system SHALL enqueue delivery of each emitted `Push` activity to the inbox of every actor in the
39 -repository's `followers` collection, using the signed, retrying delivery transport.
40 -
41 -#### Scenario: Followers are notified of a push
42 -
43 -- **WHEN** a `Push` activity is emitted for a repository that has followers
44 -- **THEN** the system enqueues a signed delivery of the activity to each follower's inbox
45 -
46 -#### Scenario: A failed delivery does not block the push
47 -
48 -- **WHEN** a follower's inbox is unreachable at push time
49 -- **THEN** the Git push still completes successfully and the delivery is retried asynchronously
DELETE openspec/changes/add-forgefed-federation/specs/federation-transport/spec.md +0 -90
diff --git a/openspec/changes/add-forgefed-federation/specs/federation-transport/spec.md b/openspec/changes/add-forgefed-federation/specs/federation-transport/spec.md
deleted file mode 100644
index c08523e..0000000
--- a/openspec/changes/add-forgefed-federation/specs/federation-transport/spec.md
+++ /dev/null
@@ -1,90 +0,0 @@
1 -## ADDED Requirements
2 -
3 -### Requirement: Outbound activities are signed with HTTP Signatures
4 -
5 -The system SHALL sign every outbound ActivityPub HTTP request with an HTTP Signature
6 -(`rsa-sha256`, draft-cavage) using the sending actor's private key. The signed string SHALL cover at
7 -least `(request-target)`, `host`, `date`, and a `digest` of the body, and the request SHALL include
8 -`Signature`, `Date`, and `Digest` headers and a `keyId` referencing the actor's `publicKey` id.
9 -
10 -#### Scenario: Delivered activity carries a valid signature
11 -
12 -- **WHEN** the system delivers an activity to a remote inbox
13 -- **THEN** the POST includes a `Signature` header with `keyId` set to the sending actor's public key
14 - id and a `Digest` matching the body
15 -
16 -### Requirement: Inbound activities must pass signature verification
17 -
18 -The system SHALL verify the HTTP Signature on every inbox POST by resolving the `keyId` to a remote
19 -actor, fetching its public key, and validating the signature and body `Digest`. The system SHALL
20 -reject (`401`) any request that is unsigned, has an invalid signature, a mismatched digest, or a
21 -stale `Date`.
22 -
23 -#### Scenario: Valid signature is accepted
24 -
25 -- **WHEN** an allowlisted peer POSTs a correctly signed activity to an inbox
26 -- **THEN** the system verifies the signature and accepts the activity (`202`)
27 -
28 -#### Scenario: Invalid or missing signature is rejected
29 -
30 -- **WHEN** an inbox POST has no signature or a signature that fails verification
31 -- **THEN** the system responds `401` and does not process the activity
32 -
33 -### Requirement: Remote actors and keys are fetched with SSRF protections
34 -
35 -The system SHALL fetch remote actor documents and public keys only over HTTPS to hosts on the peer
36 -allowlist, SHALL refuse private, loopback, and link-local addresses, SHALL cap response size and
37 -redirect count, and SHALL cache fetched actors/keys with a bounded TTL.
38 -
39 -#### Scenario: Non-allowlisted host is refused
40 -
41 -- **WHEN** the system would fetch an actor from a host not on the peer allowlist
42 -- **THEN** the fetch is refused and no request is made to that host
43 -
44 -#### Scenario: Private address target is blocked
45 -
46 -- **WHEN** a remote actor `id` or `keyId` resolves to a private/loopback/link-local IP
47 -- **THEN** the system blocks the fetch
48 -
49 -### Requirement: Inbox receipt is idempotent
50 -
51 -The system SHALL record the `id` of each accepted inbound activity and SHALL ignore a redelivery of
52 -an already-processed activity, so duplicate or replayed deliveries have no additional effect.
53 -
54 -#### Scenario: Duplicate delivery is a no-op
55 -
56 -- **WHEN** the same activity `id` is delivered twice
57 -- **THEN** the system processes it once and acknowledges the duplicate without reprocessing
58 -
59 -### Requirement: Outbound delivery is queued and retried
60 -
61 -The system SHALL persist each outbound activity delivery (target inbox, payload, attempt count, next
62 -attempt time, state) and SHALL drain the queue with a background worker, retrying failed deliveries
63 -with exponential backoff up to a maximum, after which the delivery is marked failed/dead-lettered.
64 -
65 -#### Scenario: Delivery survives a restart
66 -
67 -- **WHEN** an activity is enqueued for delivery and the service restarts before it is sent
68 -- **THEN** the worker still delivers it after restart
69 -
70 -#### Scenario: Unreachable peer is retried then dead-lettered
71 -
72 -- **WHEN** a target inbox is unreachable
73 -- **THEN** the system retries with increasing backoff and, after the maximum attempts, marks the
74 - delivery failed without blocking other deliveries
75 -
76 -### Requirement: Federation is disabled by default and bounded by an allowlist
77 -
78 -The system SHALL treat `gitshark.federation.enabled` as false unless explicitly set, and when
79 -enabled SHALL accept and send federation traffic only to/from hosts on
80 -`gitshark.federation.peer-allowlist` (an empty allowlist denies all remote peers).
81 -
82 -#### Scenario: Disabled federation serves no federation endpoints
83 -
84 -- **WHEN** federation is disabled
85 -- **THEN** inbox/outbox/actor/WebFinger federation endpoints are not served (or respond `404`)
86 -
87 -#### Scenario: Off-allowlist peer is rejected
88 -
89 -- **WHEN** an inbox POST arrives from a host not on the allowlist
90 -- **THEN** the system rejects it
DELETE openspec/changes/add-forgefed-federation/tasks.md +0 -96
diff --git a/openspec/changes/add-forgefed-federation/tasks.md b/openspec/changes/add-forgefed-federation/tasks.md
deleted file mode 100644
index f8d7222..0000000
--- a/openspec/changes/add-forgefed-federation/tasks.md
+++ /dev/null
@@ -1,96 +0,0 @@
1 -## 1. Foundation: config, schema, model
2 -
3 -- [x] 1.1 Add `gitshark.federation.enabled` (default false), `gitshark.federation.base-url`, and
4 - `gitshark.federation.peer-allowlist` to `application.properties` (+ `%test` values); add a
5 - `FederationConfig` that validates base-url is absolute and non-loopback when enabled (fail closed)
6 -- [x] 1.2 Write Flyway migration `V2__federation.sql`: `federation_keys` (actor_type, actor_ref,
7 - public_pem, private_pem, created_at), `remote_actors` (actor_id, inbox, public_key_pem, fetched_at),
8 - `repository_followers` (repository_id, follower_actor_id, created_at), `federation_inbox`
9 - (activity_id unique, received_at), `federation_delivery` (id, target_inbox, payload, attempts,
10 - next_attempt_at, state)
11 -- [x] 1.3 Add Panache entities + `Repo` interfaces for the new tables under `de.workaround.model`
12 -- [x] 1.4 `EntityPersistenceTest`-style test confirming the new entities persist and Hibernate
13 - validates against the migration (5/5 green; V2 migration applies and validates)
14 -
15 -## 2. Actors, keys, WebFinger (capability: federation-actors)
16 -
17 -- [x] 2.1 Tests first: actor JSON-LD shape (`@context`, `type`, `inbox`/`outbox`/`followers`,
18 - `publicKey`) for Repository, Person, and instance Application actors; WebFinger JRD resolves a
19 - public repo and 404s unknown/private subjects; content negotiation returns HTML by default and
20 - JSON-LD for `application/activity+json`; private repo actor β†’ 404 (`FederationActorsTest`, 8/8)
21 -- [x] 2.2 `ActorKeyService`: lazily generate + persist RSA-2048 keypairs (JCA), PEM-encode public
22 - keys, reuse across restarts
23 -- [x] 2.3 JSON-LD actor model (`ObjectNode` builders in `ActorDocuments`) with the pinned `@context`;
24 - builders for Repository, Person, Application actors and `OrderedCollection`
25 -- [x] 2.4 `ActivityPubResource` under `/ap`: actor documents, `outbox`, `followers` GET endpoints;
26 - enforce `operational()` + PUBLIC-only + base-url-present
27 -- [x] 2.5 Content negotiation on `GET /repos/{owner}/{name}`: `application/activity+json` β†’ 303 to
28 - `/ap/...`, else existing HTML
29 -- [x] 2.6 `WebFingerResource` at `/.well-known/webfinger`
30 -- [x] 2.7 Used Jackson tree nodes (no per-DTO reflection needed); `application/activity+json` produced
31 - via explicit `Response.type(...)`. Native HTTP-client registration handled in Section 3.
32 -
33 -## 3. Transport: signatures, client, inbox, delivery (capability: federation-transport)
34 -
35 -- [x] 3.1 Tests first: sign→verify round-trip; tampered body/digest fails; missing/invalid signature
36 - β†’ 401; off-allowlist host refused; private-IP target blocked; enqueued delivery survives "restart"
37 - and retries with backoff (`HttpSignaturesTest`, `RemoteUrlGuardTest`, `InboxAuthTest`,
38 - `DeliveryQueueTest` β€” 14/14; duplicate-id no-op covered by `recordAndDispatch` + Section 6)
39 -- [x] 3.2 `HttpSignatures`: build the draft-cavage signing string `(request-target) host date digest`,
40 - sign with the actor key, and verify against a fetched public key; SHA-256 `Digest` header
41 -- [x] 3.3 `ActivityPubClient`: HTTPS-only fetch of remote actors/keys with allowlist + SSRF guards
42 - (`RemoteUrlGuard`: block private/loopback/link-local, cap size/redirects), TTL cache into
43 - `remote_actors`; signed inbox POST. (Public actor docs fetched unsigned β€” they are public.)
44 -- [x] 3.4 `InboxResource` POST per actor β†’ `InboxService`: verify signature, enforce allowlist, dedup
45 - via `federation_inbox`, dispatch via `ActivityDispatcher`; respond `202`/`401`
46 -- [x] 3.5 `DeliveryService` + `@Scheduled` worker: enqueue to `federation_delivery`, drain with signed
47 - POSTs, exponential backoff, max-attempt dead-letter
48 -- [~] 3.6 HTTP client is JDK `java.net.http` (GraalVM-supported); no custom registration needed for
49 - tree-node JSON. Full native verification deferred to task 6.3.
50 -
51 -## 4. Following (capability: federation-following)
52 -
53 -- [x] 4.1 Tests first: verified `Follow` of a public repo records a follower and enqueues `Accept`;
54 - `Follow` of private/unknown repo β†’ no follower, no Accept; `Undo`(Follow) removes the follower
55 - (`FederationFollowingTest`, 3/3)
56 -- [x] 4.2 `FollowHandler`: validate object = repo actor (`LocalActors`), record follower in
57 - `repository_followers`, build + enqueue `Accept` to the follower's inbox
58 -- [x] 4.3 `UndoHandler`: remove follower on `Undo`(Follow)
59 -- [x] 4.4 `followers` collection (section 2.4 `ActivityPubResource`) reads from `repository_followers`
60 -
61 -## 5. Push announce (capability: federation-push-announce)
62 -
63 -- [x] 5.1 Tests first: real HTTP push to a public repo emits a `Push` (ref, old/new SHA, new commits,
64 - attributed to pusher) into the outbox and enqueues delivery to each follower; private repo emits
65 - nothing; unreachable follower does not fail the push (`FederationPushTest`, 3/3). SSH path shares
66 - the same `onPush` service and is covered structurally + by the Section 6 setup.
67 -- [x] 5.2 `FederationPushService.onPush(...)` β†’ `publish(...)`: build the ForgeFed `Push` (commits via
68 - `RevWalk`), assign a stable id, append to `federation_outbox`
69 -- [x] 5.3 Invoke `onPush` from smart-HTTP receive path (`GitHttpServlet` post-receive hook) with the
70 - ref updates and authenticated pusher
71 -- [x] 5.4 Invoke `onPush` from the SSH receive path (`GitSshCommandFactory` post-receive hook) through
72 - the same service
73 -- [x] 5.5 Enqueue delivery of the `Push` to all followers via `DeliveryService`
74 -
75 -## 6. End-to-end + verify
76 -
77 -- [x] 6.1 In-process cross-instance handshake test through the real verify→dedup→dispatch path: a
78 - signed `Follow` from a "remote" actor (key pre-seeded in the actor cache) is verified, recorded,
79 - and `Accept`'d; replay is idempotent; tampering β†’ 401 (`FederationHandshakeTest`, 3/3). A fully
80 - networked two-host test is deferred to 6.4 (the SSRF guard blocks loopback / requires HTTPS).
81 -- [x] 6.2 Run `./mvnw test` β€” full suite green (95/95; +43 federation tests)
82 -- [x] 6.3 Native-image smoke β€” PASSED. `./mvnw verify -Dnative -Dquarkus.native.container-build=true`
83 - built the native binary (Mandrel 25.0.3 / JDK 25, 2m4s, 115 MB) with `--link-at-build-time
84 - --no-fallback` and ran `SmokeIT` (2/2) against it. The flagged JCA risk did NOT materialize: the
85 - federation crypto (RSA keygen/`SHA256withRSA`) and `java.net.http` client are reachable in native
86 - with no extra security-services registration. (SmokeIT covers HTTP health + SSH banner; the signed
87 - federation round-trip in native is still part of the 6.4 two-host manual check.)
88 -- [x] 6.4 Two-instance trial DONE (two JVM instances on 127.0.0.1:8090/8091, mutual allowlist, dev
89 - insecure flag): a signed `Follow` from B was accepted + `Accept` delivered to B; a real `git push`
90 - to A emitted a `Push` delivered to and signature-verified by B. This surfaced + fixed a real bug:
91 - `java.net.http` negotiates HTTP/2, where the signed `Host` header becomes `:authority` and the
92 - receiver could not reconstruct it β†’ all signed deliveries 401'd. Fixed by forcing HTTP/1.1 on the
93 - client and reconstructing `host` from the request authority on verify (`FederationDeliveryRoundTripTest`
94 - regression guard). Outbound "follow a remote repo" remains a follow-up (issue #3).
95 -- [x] 6.5 `README.md` updated: Federation section, `GITSHARK_FEDERATION_*` config, persisted tables,
96 - permanent-actor-ID warning, and the note that non-git-shark interop is untested.
ADD openspec/changes/archive/2026-06-26-add-forgefed-federation/.openspec.yaml +2 -0
diff --git a/openspec/changes/archive/2026-06-26-add-forgefed-federation/.openspec.yaml b/openspec/changes/archive/2026-06-26-add-forgefed-federation/.openspec.yaml
new file mode 100644
index 0000000..6c351ac
--- /dev/null
+++ b/openspec/changes/archive/2026-06-26-add-forgefed-federation/.openspec.yaml
@@ -0,0 +1,2 @@
1 +schema: spec-driven
2 +created: 2026-06-21
ADD openspec/changes/archive/2026-06-26-add-forgefed-federation/design.md +130 -0
diff --git a/openspec/changes/archive/2026-06-26-add-forgefed-federation/design.md b/openspec/changes/archive/2026-06-26-add-forgefed-federation/design.md
new file mode 100644
index 0000000..2adabf1
--- /dev/null
+++ b/openspec/changes/archive/2026-06-26-add-forgefed-federation/design.md
@@ -0,0 +1,130 @@
1 +## Context
2 +
3 +git-shark is a single Quarkus 21 service (JGit + Apache MINA SSHD, PostgreSQL via Flyway, OIDC login,
4 +Qute UI) that compiles to a GraalVM native image. Entities today are `User`, `Repository`, `SshKey`,
5 +`AccessToken` (see `V1__init.sql`); Git access goes through `GitHttpServlet` (smart HTTP) and
6 +`SshGitBridge` (SSH). There is no issue, PR, or notification model.
7 +
8 +ForgeFed is an ActivityPub extension. ActivityPub server-to-server ("S2S") requires: actors with
9 +`inbox`/`outbox`/`followers` collections published as JSON-LD; actor discovery (WebFinger + content
10 +negotiation); authenticated delivery via **HTTP Signatures** (each actor has a keypair, the public
11 +key is published in the actor document, requests are signed with the private key and verified by the
12 +receiver fetching the sender's key). ForgeFed adds forge vocabulary: the `Repository` actor and the
13 +`Push` activity are the two we need for the first goal.
14 +
15 +Constraints: must stay native-image-safe (no runtime-classpath JSON-LD reflection magic, manual
16 +reflection registration for new model classes), reuse the already-present BouncyCastle for crypto,
17 +and not regress local Git access or OIDC. Actor IDs are absolute URLs and must never change once
18 +published, so a configured **public base URL** is foundational.
19 +
20 +## Goals / Non-Goals
21 +
22 +**Goals:**
23 +- Two git-shark instances federate: A follows a repo on B and receives B's `Push` activities.
24 +- Standards-compliant ActivityStreams + ForgeFed JSON-LD and HTTP Signatures, so non-git-shark
25 + ForgeFed software *could* interoperate later.
26 +- All federation is optional and off by default; bounded by a peer allowlist on first rollout.
27 +- Native-image compatible; no new heavyweight dependencies if avoidable.
28 +
29 +**Non-Goals:**
30 +- Tickets/issues/merge-requests federation (no local feature to federate yet).
31 +- Verified interop with Forgejo/Vervis/Mastodon (kept possible, not tested).
32 +- Cross-instance push/write access or remote-commit mirroring; following is read-only awareness.
33 +- Rich timeline/notification UI.
34 +
35 +## Decisions
36 +
37 +**Decision: A dedicated `/ap` route namespace for actor documents, with content negotiation on
38 +existing pages.** Actor IDs are `https://{host}/ap/repos/{owner}/{name}` (Repository),
39 +`https://{host}/ap/users/{username}` (Person), and `https://{host}/ap/instance` (the instance
40 +Application actor). `GET /repos/{owner}/{name}` with `Accept: application/activity+json` 303-redirects
41 +(or directly serves) the actor document, while HTML stays the default. Rationale: keeps the JSON-LD
42 +representation cleanly separable and cache-controllable, avoids overloading the HTML controllers with
43 +serialization branching, and gives a stable, greppable federation surface. Alternative β€” serve
44 +JSON-LD inline from `RepositoryResource` by sniffing `Accept` β€” rejected: muddies the UI controller
45 +and complicates native reflection scoping.
46 +
47 +**Decision: Per-actor RSA-2048 keypairs, generated lazily and stored in Postgres.** Each Repository,
48 +each federating User, and the instance actor get an RSA keypair (RSA chosen for maximum ActivityPub
49 +interop; Ed25519 is patchy across the fediverse). Keys are generated on first federation use and
50 +cached. Rationale: matches Mastodon/ForgeFed convention so signatures verify everywhere; BouncyCastle
51 +(already a dependency) does the crypto. Alternative β€” one instance-wide key signing on behalf of all
52 +actors β€” rejected: breaks the actor model and many receivers reject key/actor mismatch.
53 +
54 +**Decision: HTTP Signatures (draft-cavage, `rsa-sha256`) implemented in-house over BouncyCastle/JCA.**
55 +Sign outbound POSTs over `(request-target) host date digest` (+ `content-type`); verify inbound by
56 +parsing the `Signature` header, fetching the named `keyId` actor, and checking the digest + signature.
57 +Rationale: it is a small, well-specified algorithm; no maintained native-image-friendly Java library
58 +is worth a new dependency. Alternative β€” pull in a signing library β€” rejected for native-image risk
59 +and dependency weight.
60 +
61 +**Decision: JSON-LD by hand-built Jackson trees against a pinned `@context`, not a JSON-LD processor.**
62 +We emit/consume compacted documents with a fixed `@context` (`https://www.w3.org/ns/activitystreams`,
63 +`https://w3id.org/security/v1`, `https://forgefed.org/ns`). Serialization uses plain POJOs/`ObjectNode`
64 +via the existing Jackson; we do **not** do generic JSON-LD expansion/normalization. Rationale: full
65 +JSON-LD processing is heavy and native-image-hostile, and S2S interop in practice relies on the
66 +agreed compacted shape. Alternative β€” Titanium/jsonld-java β€” rejected: size, reflection, and
67 +overkill for fixed vocabularies.
68 +
69 +**Decision: Persisted outbound delivery queue drained by a Quarkus scheduled worker.** Outgoing
70 +activities are written to a `federation_delivery` table (target inbox, payload, attempts,
71 +next_attempt_at, state) inside the triggering transaction, then a `@Scheduled` worker POSTs them with
72 +HTTP-Signature auth and exponential backoff, marking delivered/failed. Rationale: delivery must
73 +survive restarts and tolerate offline peers; doing it inline on the push path would couple Git
74 +latency to remote availability. Alternative β€” fire-and-forget async β€” rejected: lost activities on
75 +crash/peer downtime, no retry.
76 +
77 +**Decision: Push activity is triggered by a post-receive signal from both transports.** `GitHttpServlet`
78 +and `SshGitBridge` already mediate every push; both invoke a single `FederationPushService.onPush(repo,
79 +refUpdates)` after a successful receive-pack. It builds a ForgeFed `Push` object (ref, old/new SHAs,
80 +the new commits) attributed to the pusher's Person actor, appends it to the repo `outbox`, and
81 +enqueues delivery to each follower inbox. Rationale: one choke point, both transports covered, no
82 +reliance on on-disk Git hooks (native-image and packaging friendly). Alternative β€” JGit
83 +`PostReceiveHook` scripts on disk β€” rejected: fragile under native packaging and container layout.
84 +
85 +**Decision: Inbound activities are deduplicated and signature-gated before processing.** Every inbox
86 +POST must carry a valid HTTP Signature from an allowlisted peer; the activity `id` is recorded in
87 +`federation_inbox` for idempotency; only `Follow`, `Undo`(`Follow`), and `Accept` are processed in
88 +this change (others are stored/ignored). Rationale: idempotent, secure inbox is the backbone of S2S.
89 +Alternative β€” process before dedup β€” rejected: replay and double-processing.
90 +
91 +**Decision: Federation is opt-in and allowlist-bounded.** `gitshark.federation.enabled` (default
92 +false), `gitshark.federation.base-url`, and `gitshark.federation.peer-allowlist` (host list; empty =
93 +deny all remote). Repositories federate only when `PUBLIC`. Rationale: safe, incremental rollout for
94 +the git-shark↔git-shark first goal; the allowlist can later be relaxed to open federation.
95 +
96 +## Risks / Trade-offs
97 +
98 +- **[Hand-rolled HTTP Signatures are easy to get subtly wrong β†’ interop/security bugs]** β†’ narrow,
99 + well-tested implementation against the draft-cavage signing string; round-trip tests A↔B; reject on
100 + any verification failure (fail closed).
101 +- **[SSRF / malicious actor URLs when fetching remote keys]** β†’ fetch only over HTTPS to hosts on the
102 + peer allowlist, block private/loopback/link-local IPs, cap response size and redirects.
103 +- **[Native image breakage from new Jackson model + HTTP client]** β†’ register reflection for the
104 + federation DTOs and add the client to native config; keep an integration test that runs the native
105 + binary path in CI (existing native profile).
106 +- **[Actor IDs become permanent the moment they federate]** β†’ require an explicit, validated
107 + `base-url`; refuse to emit actor documents if it is unset/localhost while `enabled=true`.
108 +- **[Delivery worker storms a downed peer]** β†’ exponential backoff with a max attempt cap and a
109 + dead-letter state; per-host serialization.
110 +- **[Key compromise]** β†’ keys are per-actor and rotatable (regenerate + republish); document the
111 + rotation path even if automation is later.
112 +
113 +## Migration Plan
114 +
115 +Additive. One new Flyway migration (`V2__federation.sql`) creates `federation_keys`,
116 +`remote_actors`, `repository_followers`, `federation_inbox`, `federation_delivery`. No existing table
117 +changes. Feature ships **disabled** (`gitshark.federation.enabled=false`); enabling it on an instance
118 +with a configured `base-url` + allowlist is the activation step. Rollback = disable the flag (actors
119 +stop being served, queue drains/stops); the migration is forward-only but inert when disabled.
120 +
121 +## Open Questions
122 +
123 +- Do Users federate as `Person` actors in this first cut, or only Repositories (with pushes attributed
124 + to a bare actor URI)? Leaning: minimal `Person` actor so `Push.attributedTo` resolves.
125 +- Actor document delivery for `GET /repos/...` with `application/activity+json`: 303-redirect to `/ap/...`
126 + vs. serve inline. Leaning redirect for clean separation.
127 +- Followers collection visibility: public vs. authenticated-only. Leaning public (read), matching most
128 + fediverse software.
129 +- Do we publish `nodeinfo` (`/.well-known/nodeinfo`) now for peer capability discovery, or defer? Likely
130 + defer to a follow-up unless a peer needs it.
ADD openspec/changes/archive/2026-06-26-add-forgefed-federation/proposal.md +83 -0
diff --git a/openspec/changes/archive/2026-06-26-add-forgefed-federation/proposal.md b/openspec/changes/archive/2026-06-26-add-forgefed-federation/proposal.md
new file mode 100644
index 0000000..61855e9
--- /dev/null
+++ b/openspec/changes/archive/2026-06-26-add-forgefed-federation/proposal.md
@@ -0,0 +1,83 @@
1 +## Why
2 +
3 +git-shark today is an island: every instance hosts its own repositories, users, and access, with no
4 +way for someone on one instance to follow, watch, or interact with a repository on another. We want
5 +**federation** so that two git-shark instances can talk to each other β€” a user on instance A can
6 +follow a repository on instance B and see its activity (pushes), without an account on B.
7 +
8 +We adopt [ForgeFed](https://forgefed.org) (an ActivityPub extension for software forges) rather than
9 +inventing a private protocol. The immediate, concrete goal is **git-shark ↔ git-shark**, but by
10 +speaking standard ActivityStreams + ForgeFed vocabulary over ActivityPub server-to-server, we stay
11 +open to interoperating with other ActivityPub/ForgeFed software (Forgejo, etc.) later β€” that
12 +interop is explicitly low priority now, but must not be designed out.
13 +
14 +## What Changes
15 +
16 +- Expose git-shark Repositories (and Users) as **ForgeFed/ActivityPub actors** with stable IDs,
17 + signing keys, and the required `inbox` / `outbox` / `followers` collections, discoverable via
18 + content negotiation (`application/activity+json`) and **WebFinger**.
19 +- Implement **server-to-server transport**: sign outgoing requests with HTTP Signatures, verify
20 + incoming signatures by fetching+caching the remote actor's public key, accept activities at
21 + actor `inbox` endpoints, and deliver outgoing activities through a persisted, retrying queue.
22 +- Implement the **Follow flow**: a remote actor sends `Follow` β†’ repository actor responds `Accept`,
23 + is added to the repository's `followers`; `Undo`(`Follow`) removes it.
24 +- Implement the **Push announce flow**: when commits are pushed to a federated repository, publish a
25 + ForgeFed `Push` activity to the repository's `outbox` and deliver it to all followers, so a remote
26 + instance learns about new commits.
27 +- Add a per-instance **federation configuration**: an enable switch, the instance's public base URL
28 + (actor IDs are absolute and must be stable), and an instance-peering **allowlist** so the first
29 + rollout can be limited to trusted git-shark peers.
30 +
31 +## Capabilities
32 +
33 +### New Capabilities
34 +
35 +- `federation-actors`: Repository and User exposed as ActivityPub/ForgeFed actors β€” JSON-LD actor
36 + documents, per-actor RSA signing keypairs, `inbox`/`outbox`/`followers` collections, an
37 + instance-level application actor, and WebFinger (`/.well-known/webfinger`) discovery via content
38 + negotiation, all native-image compatible.
39 +- `federation-transport`: ActivityPub server-to-server delivery β€” HTTP Signature signing of outbound
40 + requests and verification of inbound ones, remote actor/key fetching with caching and SSRF
41 + guards, inbox receipt + validation + deduplication, and a persisted outbound delivery queue with
42 + retry/backoff.
43 +- `federation-following`: Follow / Accept / Undo(Follow) handshake between a repository actor and a
44 + remote actor; the repository's `followers` collection is the delivery list for its activities.
45 +- `federation-push-announce`: On push to a federated repository, build and persist a ForgeFed `Push`
46 + activity, place it in the repository `outbox`, and fan it out to followers via the delivery queue.
47 +
48 +### Modified Capabilities
49 +
50 +<!-- No archived main specs exist yet (create-git-platform is not archived), so federation is added
51 + as new capabilities. The git push hook integration (git-smart-http / git-ssh-access) and the
52 + repository actor route (web-ui content negotiation) extend pending capabilities; those touch
53 + points are captured inside this change's specs rather than as separate delta specs. -->
54 +
55 +## Impact
56 +
57 +- **New code** under `de.workaround.federation.*`: actor document model + serializers, WebFinger
58 + endpoint, HTTP Signature sign/verify, an ActivityPub client (remote actor fetch + inbox POST),
59 + inbox/outbox JAX-RS resources, a delivery worker, and the Push/Follow/Accept activity builders.
60 +- **Database (new Flyway migration)**: actor signing keys, remote-actor cache, repository followers,
61 + received-activity log (dedup), and the outbound delivery queue.
62 +- **Git transport hooks**: the smart-HTTP (`GitHttpServlet`) and SSH (`SshGitBridge`) push paths must
63 + emit a post-receive signal that triggers the `Push` activity. Content negotiation on
64 + `GET /repos/{owner}/{name}` (and user pages) returns the actor JSON-LD for `application/activity+json`.
65 +- **Config**: new `gitshark.federation.*` properties (enabled flag, public base URL, peer allowlist);
66 + an HTTP client for outbound federation; scheduled delivery worker. Actor IDs depend on a correct,
67 + stable public base URL.
68 +- **Native image**: new Jackson-serialized JSON-LD model classes and the federation HTTP client need
69 + reflection/resource registration alongside the existing JGit/MINA config; crypto reuses the
70 + already-present BouncyCastle.
71 +- **Security**: signature verification is mandatory on inbound activities; remote fetches are
72 + SSRF-guarded; the peer allowlist bounds the initial blast radius. No change to OIDC login or local
73 + Git access control.
74 +
75 +## Non-Goals
76 +
77 +- Federating issues/tickets or merge/pull requests β€” git-shark has no issue or PR features yet, so
78 + ForgeFed `Offer`(`Ticket`) / patch flows are out of scope until those exist.
79 +- Interop testing against non-git-shark software (Forgejo, Vervis, Mastodon). We stay
80 + standards-compliant so it remains possible, but it is not validated here.
81 +- Federated identity/login, cross-instance authorization to push, or pulling remote commits into a
82 + local mirror. Following is read-only awareness of activity, not write access.
83 +- Inbox UI/timeline beyond what is needed to prove delivery; rich notification UX is later.
ADD openspec/changes/archive/2026-06-26-add-forgefed-federation/specs/federation-actors/spec.md +101 -0
diff --git a/openspec/changes/archive/2026-06-26-add-forgefed-federation/specs/federation-actors/spec.md b/openspec/changes/archive/2026-06-26-add-forgefed-federation/specs/federation-actors/spec.md
new file mode 100644
index 0000000..781c4a4
--- /dev/null
+++ b/openspec/changes/archive/2026-06-26-add-forgefed-federation/specs/federation-actors/spec.md
@@ -0,0 +1,101 @@
1 +## ADDED Requirements
2 +
3 +### Requirement: Repository is published as a ForgeFed Repository actor
4 +
5 +The system SHALL expose every `PUBLIC` repository as a ForgeFed `Repository` actor with a stable,
6 +absolute `id` of the form `https://{base-url}/ap/repos/{owner}/{name}`. The actor document SHALL be
7 +valid JSON-LD with `@context` including `https://www.w3.org/ns/activitystreams`,
8 +`https://w3id.org/security/v1`, and `https://forgefed.org/ns`, and SHALL include `inbox`, `outbox`,
9 +`followers`, `preferredUsername`, `name`, and a `publicKey` with the actor's PEM-encoded public key.
10 +
11 +#### Scenario: Repository actor document is served via content negotiation
12 +
13 +- **WHEN** a client requests the repository with `Accept: application/activity+json`
14 +- **THEN** the system returns the JSON-LD `Repository` actor document with `Content-Type:
15 + application/activity+json` and `type` `Repository`
16 +
17 +#### Scenario: HTML stays the default representation
18 +
19 +- **WHEN** a browser requests `GET /repos/{owner}/{name}` with an HTML `Accept` header
20 +- **THEN** the system returns the existing HTML repository page, not the actor document
21 +
22 +#### Scenario: Private repositories are not federated
23 +
24 +- **WHEN** a client requests an actor document for a `PRIVATE` repository
25 +- **THEN** the system responds `404` and does not expose any actor document
26 +
27 +### Requirement: Users are published as Person actors
28 +
29 +The system SHALL expose a federating user as an ActivityPub `Person` actor with `id`
30 +`https://{base-url}/ap/users/{username}`, an `inbox`, an `outbox`, and a `publicKey`, so that
31 +activities attributed to that user (e.g. a push) reference a resolvable actor.
32 +
33 +#### Scenario: Person actor resolves
34 +
35 +- **WHEN** a client requests `GET /ap/users/{username}` with `Accept: application/activity+json`
36 +- **THEN** the system returns a `Person` actor document with a resolvable `inbox` and `publicKey`
37 +
38 +### Requirement: An instance application actor exists
39 +
40 +The system SHALL expose a single instance-level `Application` actor at
41 +`https://{base-url}/ap/instance` with its own keypair, used to sign instance-level requests such as
42 +fetching remote actors.
43 +
44 +#### Scenario: Instance actor is available for signing
45 +
46 +- **WHEN** the system needs to fetch a remote actor document
47 +- **THEN** it signs the request as the instance `Application` actor whose `publicKey` is resolvable
48 + at `/ap/instance`
49 +
50 +### Requirement: Each actor has a persistent signing keypair
51 +
52 +The system SHALL generate an RSA-2048 keypair per federating actor (repository, user, instance) on
53 +first federation use and persist it, so the published `publicKey` is stable and the private key is
54 +reused across restarts.
55 +
56 +#### Scenario: Key is generated once and reused
57 +
58 +- **WHEN** an actor federates for the first time
59 +- **THEN** the system generates and stores a keypair
60 +- **AND WHEN** the same actor federates again after a restart
61 +- **THEN** the system reuses the stored keypair and publishes the same `publicKey`
62 +
63 +### Requirement: Actors expose inbox, outbox, and followers collections
64 +
65 +The system SHALL serve, for each actor, an `inbox` (POST endpoint for incoming activities), an
66 +`outbox` (ordered collection of the actor's published activities), and a `followers` ordered
67 +collection, each addressable at the URL named in the actor document.
68 +
69 +#### Scenario: Collections are reachable
70 +
71 +- **WHEN** a client requests an actor's `outbox` or `followers` URL with `Accept:
72 + application/activity+json`
73 +- **THEN** the system returns an `OrderedCollection` JSON-LD document
74 +
75 +### Requirement: Actors are discoverable via WebFinger
76 +
77 +The system SHALL respond to `GET /.well-known/webfinger?resource=acct:{name}@{host}` for repository
78 +and user actors, returning a JRD document whose `links` include a `self` link of type
79 +`application/activity+json` pointing to the actor `id`.
80 +
81 +#### Scenario: WebFinger resolves a repository actor
82 +
83 +- **WHEN** a client requests `GET /.well-known/webfinger?resource=acct:{owner}/{name}@{host}` for a
84 + public repository
85 +- **THEN** the system returns a `200` JRD with a `self` link to the repository actor `id`
86 +
87 +#### Scenario: WebFinger rejects unknown subjects
88 +
89 +- **WHEN** a WebFinger request names a subject that does not exist or is private
90 +- **THEN** the system responds `404`
91 +
92 +### Requirement: Federation requires a configured public base URL
93 +
94 +When `gitshark.federation.enabled` is true, the system SHALL require a valid, absolute, non-loopback
95 +`gitshark.federation.base-url`, and SHALL refuse to emit actor documents (failing closed) if it is
96 +unset or points at localhost, because actor IDs are permanent once published.
97 +
98 +#### Scenario: Missing base URL blocks actor emission
99 +
100 +- **WHEN** federation is enabled but `base-url` is unset or a loopback address
101 +- **THEN** the system does not serve actor documents and surfaces a configuration error
ADD openspec/changes/archive/2026-06-26-add-forgefed-federation/specs/federation-following/spec.md +50 -0
diff --git a/openspec/changes/archive/2026-06-26-add-forgefed-federation/specs/federation-following/spec.md b/openspec/changes/archive/2026-06-26-add-forgefed-federation/specs/federation-following/spec.md
new file mode 100644
index 0000000..c4d2d93
--- /dev/null
+++ b/openspec/changes/archive/2026-06-26-add-forgefed-federation/specs/federation-following/spec.md
@@ -0,0 +1,50 @@
1 +## ADDED Requirements
2 +
3 +### Requirement: A remote actor can follow a repository
4 +
5 +The system SHALL accept a `Follow` activity addressed to a repository actor's inbox, whose `object`
6 +is the repository actor `id`, from an allowlisted, signature-verified remote actor, and SHALL add
7 +that actor to the repository's `followers` collection.
8 +
9 +#### Scenario: Follow is accepted and recorded
10 +
11 +- **WHEN** a verified remote actor POSTs a `Follow` of a public repository to its inbox
12 +- **THEN** the system records the remote actor in the repository's `followers` collection
13 +
14 +#### Scenario: Follow of a private or unknown repository is refused
15 +
16 +- **WHEN** a `Follow` targets a private or non-existent repository
17 +- **THEN** the system does not add a follower and does not send an `Accept`
18 +
19 +### Requirement: The repository responds with Accept
20 +
21 +After recording a follower, the system SHALL deliver an `Accept` activity, whose `object` is the
22 +original `Follow`, from the repository actor to the follower's inbox.
23 +
24 +#### Scenario: Accept is delivered to the follower
25 +
26 +- **WHEN** a `Follow` has been accepted and the follower recorded
27 +- **THEN** the system enqueues an `Accept` activity for delivery to the follower's inbox referencing
28 + the original `Follow`
29 +
30 +### Requirement: A follower can undo its follow
31 +
32 +The system SHALL accept an `Undo` activity whose `object` is a previously accepted `Follow` and SHALL
33 +remove the actor from the repository's `followers` collection, stopping further activity delivery to
34 +it.
35 +
36 +#### Scenario: Undo removes the follower
37 +
38 +- **WHEN** a current follower POSTs a verified `Undo` of its earlier `Follow`
39 +- **THEN** the system removes that actor from the `followers` collection
40 +- **AND** subsequent repository activities are no longer delivered to it
41 +
42 +### Requirement: The followers collection is the delivery audience
43 +
44 +The system SHALL treat the repository's `followers` collection as the set of inboxes to which the
45 +repository's published activities are delivered.
46 +
47 +#### Scenario: Followers receive repository activities
48 +
49 +- **WHEN** the repository publishes an activity to its outbox
50 +- **THEN** the system enqueues delivery to the inbox of every actor in its `followers` collection
ADD openspec/changes/archive/2026-06-26-add-forgefed-federation/specs/federation-push-announce/spec.md +49 -0
diff --git a/openspec/changes/archive/2026-06-26-add-forgefed-federation/specs/federation-push-announce/spec.md b/openspec/changes/archive/2026-06-26-add-forgefed-federation/specs/federation-push-announce/spec.md
new file mode 100644
index 0000000..16294ad
--- /dev/null
+++ b/openspec/changes/archive/2026-06-26-add-forgefed-federation/specs/federation-push-announce/spec.md
@@ -0,0 +1,49 @@
1 +## ADDED Requirements
2 +
3 +### Requirement: A successful push to a federated repository emits a Push activity
4 +
5 +The system SHALL build a ForgeFed `Push` activity when commits are received on a `PUBLIC` repository
6 +over either Git transport (smart HTTP or SSH) and federation is enabled. The activity SHALL describe
7 +the updated ref, the previous and new commit ids, and the newly received commits, attributed to the
8 +pusher's Person actor.
9 +
10 +#### Scenario: Push over HTTP emits a Push activity
11 +
12 +- **WHEN** a user pushes new commits to a public repository over smart HTTP
13 +- **THEN** the system builds a `Push` activity referencing the repository, the updated ref, the
14 + old/new commit SHAs, and is attributed to the pusher
15 +
16 +#### Scenario: Push over SSH emits a Push activity
17 +
18 +- **WHEN** a user pushes new commits to a public repository over SSH
19 +- **THEN** the system builds an equivalent `Push` activity through the same code path
20 +
21 +#### Scenario: Push to a private repository emits nothing
22 +
23 +- **WHEN** a push targets a `PRIVATE` repository, or federation is disabled
24 +- **THEN** the system does not build or deliver any activity
25 +
26 +### Requirement: Push activities are added to the repository outbox
27 +
28 +The system SHALL append each emitted `Push` activity to the repository actor's `outbox` ordered
29 +collection with a stable activity `id`.
30 +
31 +#### Scenario: Push appears in the outbox
32 +
33 +- **WHEN** a `Push` activity has been emitted
34 +- **THEN** it is present in the repository's `outbox` collection with a resolvable activity `id`
35 +
36 +### Requirement: Push activities are delivered to followers
37 +
38 +The system SHALL enqueue delivery of each emitted `Push` activity to the inbox of every actor in the
39 +repository's `followers` collection, using the signed, retrying delivery transport.
40 +
41 +#### Scenario: Followers are notified of a push
42 +
43 +- **WHEN** a `Push` activity is emitted for a repository that has followers
44 +- **THEN** the system enqueues a signed delivery of the activity to each follower's inbox
45 +
46 +#### Scenario: A failed delivery does not block the push
47 +
48 +- **WHEN** a follower's inbox is unreachable at push time
49 +- **THEN** the Git push still completes successfully and the delivery is retried asynchronously
ADD openspec/changes/archive/2026-06-26-add-forgefed-federation/specs/federation-transport/spec.md +90 -0
diff --git a/openspec/changes/archive/2026-06-26-add-forgefed-federation/specs/federation-transport/spec.md b/openspec/changes/archive/2026-06-26-add-forgefed-federation/specs/federation-transport/spec.md
new file mode 100644
index 0000000..c08523e
--- /dev/null
+++ b/openspec/changes/archive/2026-06-26-add-forgefed-federation/specs/federation-transport/spec.md
@@ -0,0 +1,90 @@
1 +## ADDED Requirements
2 +
3 +### Requirement: Outbound activities are signed with HTTP Signatures
4 +
5 +The system SHALL sign every outbound ActivityPub HTTP request with an HTTP Signature
6 +(`rsa-sha256`, draft-cavage) using the sending actor's private key. The signed string SHALL cover at
7 +least `(request-target)`, `host`, `date`, and a `digest` of the body, and the request SHALL include
8 +`Signature`, `Date`, and `Digest` headers and a `keyId` referencing the actor's `publicKey` id.
9 +
10 +#### Scenario: Delivered activity carries a valid signature
11 +
12 +- **WHEN** the system delivers an activity to a remote inbox
13 +- **THEN** the POST includes a `Signature` header with `keyId` set to the sending actor's public key
14 + id and a `Digest` matching the body
15 +
16 +### Requirement: Inbound activities must pass signature verification
17 +
18 +The system SHALL verify the HTTP Signature on every inbox POST by resolving the `keyId` to a remote
19 +actor, fetching its public key, and validating the signature and body `Digest`. The system SHALL
20 +reject (`401`) any request that is unsigned, has an invalid signature, a mismatched digest, or a
21 +stale `Date`.
22 +
23 +#### Scenario: Valid signature is accepted
24 +
25 +- **WHEN** an allowlisted peer POSTs a correctly signed activity to an inbox
26 +- **THEN** the system verifies the signature and accepts the activity (`202`)
27 +
28 +#### Scenario: Invalid or missing signature is rejected
29 +
30 +- **WHEN** an inbox POST has no signature or a signature that fails verification
31 +- **THEN** the system responds `401` and does not process the activity
32 +
33 +### Requirement: Remote actors and keys are fetched with SSRF protections
34 +
35 +The system SHALL fetch remote actor documents and public keys only over HTTPS to hosts on the peer
36 +allowlist, SHALL refuse private, loopback, and link-local addresses, SHALL cap response size and
37 +redirect count, and SHALL cache fetched actors/keys with a bounded TTL.
38 +
39 +#### Scenario: Non-allowlisted host is refused
40 +
41 +- **WHEN** the system would fetch an actor from a host not on the peer allowlist
42 +- **THEN** the fetch is refused and no request is made to that host
43 +
44 +#### Scenario: Private address target is blocked
45 +
46 +- **WHEN** a remote actor `id` or `keyId` resolves to a private/loopback/link-local IP
47 +- **THEN** the system blocks the fetch
48 +
49 +### Requirement: Inbox receipt is idempotent
50 +
51 +The system SHALL record the `id` of each accepted inbound activity and SHALL ignore a redelivery of
52 +an already-processed activity, so duplicate or replayed deliveries have no additional effect.
53 +
54 +#### Scenario: Duplicate delivery is a no-op
55 +
56 +- **WHEN** the same activity `id` is delivered twice
57 +- **THEN** the system processes it once and acknowledges the duplicate without reprocessing
58 +
59 +### Requirement: Outbound delivery is queued and retried
60 +
61 +The system SHALL persist each outbound activity delivery (target inbox, payload, attempt count, next
62 +attempt time, state) and SHALL drain the queue with a background worker, retrying failed deliveries
63 +with exponential backoff up to a maximum, after which the delivery is marked failed/dead-lettered.
64 +
65 +#### Scenario: Delivery survives a restart
66 +
67 +- **WHEN** an activity is enqueued for delivery and the service restarts before it is sent
68 +- **THEN** the worker still delivers it after restart
69 +
70 +#### Scenario: Unreachable peer is retried then dead-lettered
71 +
72 +- **WHEN** a target inbox is unreachable
73 +- **THEN** the system retries with increasing backoff and, after the maximum attempts, marks the
74 + delivery failed without blocking other deliveries
75 +
76 +### Requirement: Federation is disabled by default and bounded by an allowlist
77 +
78 +The system SHALL treat `gitshark.federation.enabled` as false unless explicitly set, and when
79 +enabled SHALL accept and send federation traffic only to/from hosts on
80 +`gitshark.federation.peer-allowlist` (an empty allowlist denies all remote peers).
81 +
82 +#### Scenario: Disabled federation serves no federation endpoints
83 +
84 +- **WHEN** federation is disabled
85 +- **THEN** inbox/outbox/actor/WebFinger federation endpoints are not served (or respond `404`)
86 +
87 +#### Scenario: Off-allowlist peer is rejected
88 +
89 +- **WHEN** an inbox POST arrives from a host not on the allowlist
90 +- **THEN** the system rejects it
ADD openspec/changes/archive/2026-06-26-add-forgefed-federation/tasks.md +96 -0
diff --git a/openspec/changes/archive/2026-06-26-add-forgefed-federation/tasks.md b/openspec/changes/archive/2026-06-26-add-forgefed-federation/tasks.md
new file mode 100644
index 0000000..f8d7222
--- /dev/null
+++ b/openspec/changes/archive/2026-06-26-add-forgefed-federation/tasks.md
@@ -0,0 +1,96 @@
1 +## 1. Foundation: config, schema, model
2 +
3 +- [x] 1.1 Add `gitshark.federation.enabled` (default false), `gitshark.federation.base-url`, and
4 + `gitshark.federation.peer-allowlist` to `application.properties` (+ `%test` values); add a
5 + `FederationConfig` that validates base-url is absolute and non-loopback when enabled (fail closed)
6 +- [x] 1.2 Write Flyway migration `V2__federation.sql`: `federation_keys` (actor_type, actor_ref,
7 + public_pem, private_pem, created_at), `remote_actors` (actor_id, inbox, public_key_pem, fetched_at),
8 + `repository_followers` (repository_id, follower_actor_id, created_at), `federation_inbox`
9 + (activity_id unique, received_at), `federation_delivery` (id, target_inbox, payload, attempts,
10 + next_attempt_at, state)
11 +- [x] 1.3 Add Panache entities + `Repo` interfaces for the new tables under `de.workaround.model`
12 +- [x] 1.4 `EntityPersistenceTest`-style test confirming the new entities persist and Hibernate
13 + validates against the migration (5/5 green; V2 migration applies and validates)
14 +
15 +## 2. Actors, keys, WebFinger (capability: federation-actors)
16 +
17 +- [x] 2.1 Tests first: actor JSON-LD shape (`@context`, `type`, `inbox`/`outbox`/`followers`,
18 + `publicKey`) for Repository, Person, and instance Application actors; WebFinger JRD resolves a
19 + public repo and 404s unknown/private subjects; content negotiation returns HTML by default and
20 + JSON-LD for `application/activity+json`; private repo actor β†’ 404 (`FederationActorsTest`, 8/8)
21 +- [x] 2.2 `ActorKeyService`: lazily generate + persist RSA-2048 keypairs (JCA), PEM-encode public
22 + keys, reuse across restarts
23 +- [x] 2.3 JSON-LD actor model (`ObjectNode` builders in `ActorDocuments`) with the pinned `@context`;
24 + builders for Repository, Person, Application actors and `OrderedCollection`
25 +- [x] 2.4 `ActivityPubResource` under `/ap`: actor documents, `outbox`, `followers` GET endpoints;
26 + enforce `operational()` + PUBLIC-only + base-url-present
27 +- [x] 2.5 Content negotiation on `GET /repos/{owner}/{name}`: `application/activity+json` β†’ 303 to
28 + `/ap/...`, else existing HTML
29 +- [x] 2.6 `WebFingerResource` at `/.well-known/webfinger`
30 +- [x] 2.7 Used Jackson tree nodes (no per-DTO reflection needed); `application/activity+json` produced
31 + via explicit `Response.type(...)`. Native HTTP-client registration handled in Section 3.
32 +
33 +## 3. Transport: signatures, client, inbox, delivery (capability: federation-transport)
34 +
35 +- [x] 3.1 Tests first: sign→verify round-trip; tampered body/digest fails; missing/invalid signature
36 + β†’ 401; off-allowlist host refused; private-IP target blocked; enqueued delivery survives "restart"
37 + and retries with backoff (`HttpSignaturesTest`, `RemoteUrlGuardTest`, `InboxAuthTest`,
38 + `DeliveryQueueTest` β€” 14/14; duplicate-id no-op covered by `recordAndDispatch` + Section 6)
39 +- [x] 3.2 `HttpSignatures`: build the draft-cavage signing string `(request-target) host date digest`,
40 + sign with the actor key, and verify against a fetched public key; SHA-256 `Digest` header
41 +- [x] 3.3 `ActivityPubClient`: HTTPS-only fetch of remote actors/keys with allowlist + SSRF guards
42 + (`RemoteUrlGuard`: block private/loopback/link-local, cap size/redirects), TTL cache into
43 + `remote_actors`; signed inbox POST. (Public actor docs fetched unsigned β€” they are public.)
44 +- [x] 3.4 `InboxResource` POST per actor β†’ `InboxService`: verify signature, enforce allowlist, dedup
45 + via `federation_inbox`, dispatch via `ActivityDispatcher`; respond `202`/`401`
46 +- [x] 3.5 `DeliveryService` + `@Scheduled` worker: enqueue to `federation_delivery`, drain with signed
47 + POSTs, exponential backoff, max-attempt dead-letter
48 +- [~] 3.6 HTTP client is JDK `java.net.http` (GraalVM-supported); no custom registration needed for
49 + tree-node JSON. Full native verification deferred to task 6.3.
50 +
51 +## 4. Following (capability: federation-following)
52 +
53 +- [x] 4.1 Tests first: verified `Follow` of a public repo records a follower and enqueues `Accept`;
54 + `Follow` of private/unknown repo β†’ no follower, no Accept; `Undo`(Follow) removes the follower
55 + (`FederationFollowingTest`, 3/3)
56 +- [x] 4.2 `FollowHandler`: validate object = repo actor (`LocalActors`), record follower in
57 + `repository_followers`, build + enqueue `Accept` to the follower's inbox
58 +- [x] 4.3 `UndoHandler`: remove follower on `Undo`(Follow)
59 +- [x] 4.4 `followers` collection (section 2.4 `ActivityPubResource`) reads from `repository_followers`
60 +
61 +## 5. Push announce (capability: federation-push-announce)
62 +
63 +- [x] 5.1 Tests first: real HTTP push to a public repo emits a `Push` (ref, old/new SHA, new commits,
64 + attributed to pusher) into the outbox and enqueues delivery to each follower; private repo emits
65 + nothing; unreachable follower does not fail the push (`FederationPushTest`, 3/3). SSH path shares
66 + the same `onPush` service and is covered structurally + by the Section 6 setup.
67 +- [x] 5.2 `FederationPushService.onPush(...)` β†’ `publish(...)`: build the ForgeFed `Push` (commits via
68 + `RevWalk`), assign a stable id, append to `federation_outbox`
69 +- [x] 5.3 Invoke `onPush` from smart-HTTP receive path (`GitHttpServlet` post-receive hook) with the
70 + ref updates and authenticated pusher
71 +- [x] 5.4 Invoke `onPush` from the SSH receive path (`GitSshCommandFactory` post-receive hook) through
72 + the same service
73 +- [x] 5.5 Enqueue delivery of the `Push` to all followers via `DeliveryService`
74 +
75 +## 6. End-to-end + verify
76 +
77 +- [x] 6.1 In-process cross-instance handshake test through the real verify→dedup→dispatch path: a
78 + signed `Follow` from a "remote" actor (key pre-seeded in the actor cache) is verified, recorded,
79 + and `Accept`'d; replay is idempotent; tampering β†’ 401 (`FederationHandshakeTest`, 3/3). A fully
80 + networked two-host test is deferred to 6.4 (the SSRF guard blocks loopback / requires HTTPS).
81 +- [x] 6.2 Run `./mvnw test` β€” full suite green (95/95; +43 federation tests)
82 +- [x] 6.3 Native-image smoke β€” PASSED. `./mvnw verify -Dnative -Dquarkus.native.container-build=true`
83 + built the native binary (Mandrel 25.0.3 / JDK 25, 2m4s, 115 MB) with `--link-at-build-time
84 + --no-fallback` and ran `SmokeIT` (2/2) against it. The flagged JCA risk did NOT materialize: the
85 + federation crypto (RSA keygen/`SHA256withRSA`) and `java.net.http` client are reachable in native
86 + with no extra security-services registration. (SmokeIT covers HTTP health + SSH banner; the signed
87 + federation round-trip in native is still part of the 6.4 two-host manual check.)
88 +- [x] 6.4 Two-instance trial DONE (two JVM instances on 127.0.0.1:8090/8091, mutual allowlist, dev
89 + insecure flag): a signed `Follow` from B was accepted + `Accept` delivered to B; a real `git push`
90 + to A emitted a `Push` delivered to and signature-verified by B. This surfaced + fixed a real bug:
91 + `java.net.http` negotiates HTTP/2, where the signed `Host` header becomes `:authority` and the
92 + receiver could not reconstruct it β†’ all signed deliveries 401'd. Fixed by forcing HTTP/1.1 on the
93 + client and reconstructing `host` from the request authority on verify (`FederationDeliveryRoundTripTest`
94 + regression guard). Outbound "follow a remote repo" remains a follow-up (issue #3).
95 +- [x] 6.5 `README.md` updated: Federation section, `GITSHARK_FEDERATION_*` config, persisted tables,
96 + permanent-actor-ID warning, and the note that non-git-shark interop is untested.
ADD openspec/specs/federation-actors/spec.md +109 -0
diff --git a/openspec/specs/federation-actors/spec.md b/openspec/specs/federation-actors/spec.md
new file mode 100644
index 0000000..0e0f01e
--- /dev/null
+++ b/openspec/specs/federation-actors/spec.md
@@ -0,0 +1,109 @@
1 +# federation-actors Specification
2 +
3 +## Purpose
4 +
5 +Publish git-shark repositories, users, and the instance itself as ActivityPub/ForgeFed actors with
6 +stable IDs, signing keypairs, inbox/outbox/followers collections, and WebFinger discovery, so that
7 +they can participate in the fediverse.
8 +
9 +## Requirements
10 +
11 +### Requirement: Repository is published as a ForgeFed Repository actor
12 +
13 +The system SHALL expose every `PUBLIC` repository as a ForgeFed `Repository` actor with a stable,
14 +absolute `id` of the form `https://{base-url}/ap/repos/{owner}/{name}`. The actor document SHALL be
15 +valid JSON-LD with `@context` including `https://www.w3.org/ns/activitystreams`,
16 +`https://w3id.org/security/v1`, and `https://forgefed.org/ns`, and SHALL include `inbox`, `outbox`,
17 +`followers`, `preferredUsername`, `name`, and a `publicKey` with the actor's PEM-encoded public key.
18 +
19 +#### Scenario: Repository actor document is served via content negotiation
20 +
21 +- **WHEN** a client requests the repository with `Accept: application/activity+json`
22 +- **THEN** the system returns the JSON-LD `Repository` actor document with `Content-Type:
23 + application/activity+json` and `type` `Repository`
24 +
25 +#### Scenario: HTML stays the default representation
26 +
27 +- **WHEN** a browser requests `GET /repos/{owner}/{name}` with an HTML `Accept` header
28 +- **THEN** the system returns the existing HTML repository page, not the actor document
29 +
30 +#### Scenario: Private repositories are not federated
31 +
32 +- **WHEN** a client requests an actor document for a `PRIVATE` repository
33 +- **THEN** the system responds `404` and does not expose any actor document
34 +
35 +### Requirement: Users are published as Person actors
36 +
37 +The system SHALL expose a federating user as an ActivityPub `Person` actor with `id`
38 +`https://{base-url}/ap/users/{username}`, an `inbox`, an `outbox`, and a `publicKey`, so that
39 +activities attributed to that user (e.g. a push) reference a resolvable actor.
40 +
41 +#### Scenario: Person actor resolves
42 +
43 +- **WHEN** a client requests `GET /ap/users/{username}` with `Accept: application/activity+json`
44 +- **THEN** the system returns a `Person` actor document with a resolvable `inbox` and `publicKey`
45 +
46 +### Requirement: An instance application actor exists
47 +
48 +The system SHALL expose a single instance-level `Application` actor at
49 +`https://{base-url}/ap/instance` with its own keypair, used to sign instance-level requests such as
50 +fetching remote actors.
51 +
52 +#### Scenario: Instance actor is available for signing
53 +
54 +- **WHEN** the system needs to fetch a remote actor document
55 +- **THEN** it signs the request as the instance `Application` actor whose `publicKey` is resolvable
56 + at `/ap/instance`
57 +
58 +### Requirement: Each actor has a persistent signing keypair
59 +
60 +The system SHALL generate an RSA-2048 keypair per federating actor (repository, user, instance) on
61 +first federation use and persist it, so the published `publicKey` is stable and the private key is
62 +reused across restarts.
63 +
64 +#### Scenario: Key is generated once and reused
65 +
66 +- **WHEN** an actor federates for the first time
67 +- **THEN** the system generates and stores a keypair
68 +- **AND WHEN** the same actor federates again after a restart
69 +- **THEN** the system reuses the stored keypair and publishes the same `publicKey`
70 +
71 +### Requirement: Actors expose inbox, outbox, and followers collections
72 +
73 +The system SHALL serve, for each actor, an `inbox` (POST endpoint for incoming activities), an
74 +`outbox` (ordered collection of the actor's published activities), and a `followers` ordered
75 +collection, each addressable at the URL named in the actor document.
76 +
77 +#### Scenario: Collections are reachable
78 +
79 +- **WHEN** a client requests an actor's `outbox` or `followers` URL with `Accept:
80 + application/activity+json`
81 +- **THEN** the system returns an `OrderedCollection` JSON-LD document
82 +
83 +### Requirement: Actors are discoverable via WebFinger
84 +
85 +The system SHALL respond to `GET /.well-known/webfinger?resource=acct:{name}@{host}` for repository
86 +and user actors, returning a JRD document whose `links` include a `self` link of type
87 +`application/activity+json` pointing to the actor `id`.
88 +
89 +#### Scenario: WebFinger resolves a repository actor
90 +
91 +- **WHEN** a client requests `GET /.well-known/webfinger?resource=acct:{owner}/{name}@{host}` for a
92 + public repository
93 +- **THEN** the system returns a `200` JRD with a `self` link to the repository actor `id`
94 +
95 +#### Scenario: WebFinger rejects unknown subjects
96 +
97 +- **WHEN** a WebFinger request names a subject that does not exist or is private
98 +- **THEN** the system responds `404`
99 +
100 +### Requirement: Federation requires a configured public base URL
101 +
102 +When `gitshark.federation.enabled` is true, the system SHALL require a valid, absolute, non-loopback
103 +`gitshark.federation.base-url`, and SHALL refuse to emit actor documents (failing closed) if it is
104 +unset or points at localhost, because actor IDs are permanent once published.
105 +
106 +#### Scenario: Missing base URL blocks actor emission
107 +
108 +- **WHEN** federation is enabled but `base-url` is unset or a loopback address
109 +- **THEN** the system does not serve actor documents and surfaces a configuration error
ADD openspec/specs/federation-following/spec.md +57 -0
diff --git a/openspec/specs/federation-following/spec.md b/openspec/specs/federation-following/spec.md
new file mode 100644
index 0000000..2a67c0e
--- /dev/null
+++ b/openspec/specs/federation-following/spec.md
@@ -0,0 +1,57 @@
1 +# federation-following Specification
2 +
3 +## Purpose
4 +
5 +Allow remote actors to follow git-shark repository actors, respond with `Accept`, support `Undo` of a
6 +follow, and use the followers collection as the delivery audience for repository activities.
7 +
8 +## Requirements
9 +
10 +### Requirement: A remote actor can follow a repository
11 +
12 +The system SHALL accept a `Follow` activity addressed to a repository actor's inbox, whose `object`
13 +is the repository actor `id`, from an allowlisted, signature-verified remote actor, and SHALL add
14 +that actor to the repository's `followers` collection.
15 +
16 +#### Scenario: Follow is accepted and recorded
17 +
18 +- **WHEN** a verified remote actor POSTs a `Follow` of a public repository to its inbox
19 +- **THEN** the system records the remote actor in the repository's `followers` collection
20 +
21 +#### Scenario: Follow of a private or unknown repository is refused
22 +
23 +- **WHEN** a `Follow` targets a private or non-existent repository
24 +- **THEN** the system does not add a follower and does not send an `Accept`
25 +
26 +### Requirement: The repository responds with Accept
27 +
28 +After recording a follower, the system SHALL deliver an `Accept` activity, whose `object` is the
29 +original `Follow`, from the repository actor to the follower's inbox.
30 +
31 +#### Scenario: Accept is delivered to the follower
32 +
33 +- **WHEN** a `Follow` has been accepted and the follower recorded
34 +- **THEN** the system enqueues an `Accept` activity for delivery to the follower's inbox referencing
35 + the original `Follow`
36 +
37 +### Requirement: A follower can undo its follow
38 +
39 +The system SHALL accept an `Undo` activity whose `object` is a previously accepted `Follow` and SHALL
40 +remove the actor from the repository's `followers` collection, stopping further activity delivery to
41 +it.
42 +
43 +#### Scenario: Undo removes the follower
44 +
45 +- **WHEN** a current follower POSTs a verified `Undo` of its earlier `Follow`
46 +- **THEN** the system removes that actor from the `followers` collection
47 +- **AND** subsequent repository activities are no longer delivered to it
48 +
49 +### Requirement: The followers collection is the delivery audience
50 +
51 +The system SHALL treat the repository's `followers` collection as the set of inboxes to which the
52 +repository's published activities are delivered.
53 +
54 +#### Scenario: Followers receive repository activities
55 +
56 +- **WHEN** the repository publishes an activity to its outbox
57 +- **THEN** the system enqueues delivery to the inbox of every actor in its `followers` collection
ADD openspec/specs/federation-push-announce/spec.md +56 -0
diff --git a/openspec/specs/federation-push-announce/spec.md b/openspec/specs/federation-push-announce/spec.md
new file mode 100644
index 0000000..1f70175
--- /dev/null
+++ b/openspec/specs/federation-push-announce/spec.md
@@ -0,0 +1,56 @@
1 +# federation-push-announce Specification
2 +
3 +## Purpose
4 +
5 +Emit a ForgeFed `Push` activity when commits are received on a public, federated repository, append
6 +it to the repository outbox, and deliver it to followers.
7 +
8 +## Requirements
9 +
10 +### Requirement: A successful push to a federated repository emits a Push activity
11 +
12 +The system SHALL build a ForgeFed `Push` activity when commits are received on a `PUBLIC` repository
13 +over either Git transport (smart HTTP or SSH) and federation is enabled. The activity SHALL describe
14 +the updated ref, the previous and new commit ids, and the newly received commits, attributed to the
15 +pusher's Person actor.
16 +
17 +#### Scenario: Push over HTTP emits a Push activity
18 +
19 +- **WHEN** a user pushes new commits to a public repository over smart HTTP
20 +- **THEN** the system builds a `Push` activity referencing the repository, the updated ref, the
21 + old/new commit SHAs, and is attributed to the pusher
22 +
23 +#### Scenario: Push over SSH emits a Push activity
24 +
25 +- **WHEN** a user pushes new commits to a public repository over SSH
26 +- **THEN** the system builds an equivalent `Push` activity through the same code path
27 +
28 +#### Scenario: Push to a private repository emits nothing
29 +
30 +- **WHEN** a push targets a `PRIVATE` repository, or federation is disabled
31 +- **THEN** the system does not build or deliver any activity
32 +
33 +### Requirement: Push activities are added to the repository outbox
34 +
35 +The system SHALL append each emitted `Push` activity to the repository actor's `outbox` ordered
36 +collection with a stable activity `id`.
37 +
38 +#### Scenario: Push appears in the outbox
39 +
40 +- **WHEN** a `Push` activity has been emitted
41 +- **THEN** it is present in the repository's `outbox` collection with a resolvable activity `id`
42 +
43 +### Requirement: Push activities are delivered to followers
44 +
45 +The system SHALL enqueue delivery of each emitted `Push` activity to the inbox of every actor in the
46 +repository's `followers` collection, using the signed, retrying delivery transport.
47 +
48 +#### Scenario: Followers are notified of a push
49 +
50 +- **WHEN** a `Push` activity is emitted for a repository that has followers
51 +- **THEN** the system enqueues a signed delivery of the activity to each follower's inbox
52 +
53 +#### Scenario: A failed delivery does not block the push
54 +
55 +- **WHEN** a follower's inbox is unreachable at push time
56 +- **THEN** the Git push still completes successfully and the delivery is retried asynchronously
ADD openspec/specs/federation-transport/spec.md +98 -0
diff --git a/openspec/specs/federation-transport/spec.md b/openspec/specs/federation-transport/spec.md
new file mode 100644
index 0000000..be99710
--- /dev/null
+++ b/openspec/specs/federation-transport/spec.md
@@ -0,0 +1,98 @@
1 +# federation-transport Specification
2 +
3 +## Purpose
4 +
5 +Provide the secure transport layer for federation: HTTP Signature signing and verification, SSRF
6 +protections on remote fetches, idempotent inbox processing, queued and retried outbound delivery, and
7 +an allowlist that is disabled by default.
8 +
9 +## Requirements
10 +
11 +### Requirement: Outbound activities are signed with HTTP Signatures
12 +
13 +The system SHALL sign every outbound ActivityPub HTTP request with an HTTP Signature
14 +(`rsa-sha256`, draft-cavage) using the sending actor's private key. The signed string SHALL cover at
15 +least `(request-target)`, `host`, `date`, and a `digest` of the body, and the request SHALL include
16 +`Signature`, `Date`, and `Digest` headers and a `keyId` referencing the actor's `publicKey` id.
17 +
18 +#### Scenario: Delivered activity carries a valid signature
19 +
20 +- **WHEN** the system delivers an activity to a remote inbox
21 +- **THEN** the POST includes a `Signature` header with `keyId` set to the sending actor's public key
22 + id and a `Digest` matching the body
23 +
24 +### Requirement: Inbound activities must pass signature verification
25 +
26 +The system SHALL verify the HTTP Signature on every inbox POST by resolving the `keyId` to a remote
27 +actor, fetching its public key, and validating the signature and body `Digest`. The system SHALL
28 +reject (`401`) any request that is unsigned, has an invalid signature, a mismatched digest, or a
29 +stale `Date`.
30 +
31 +#### Scenario: Valid signature is accepted
32 +
33 +- **WHEN** an allowlisted peer POSTs a correctly signed activity to an inbox
34 +- **THEN** the system verifies the signature and accepts the activity (`202`)
35 +
36 +#### Scenario: Invalid or missing signature is rejected
37 +
38 +- **WHEN** an inbox POST has no signature or a signature that fails verification
39 +- **THEN** the system responds `401` and does not process the activity
40 +
41 +### Requirement: Remote actors and keys are fetched with SSRF protections
42 +
43 +The system SHALL fetch remote actor documents and public keys only over HTTPS to hosts on the peer
44 +allowlist, SHALL refuse private, loopback, and link-local addresses, SHALL cap response size and
45 +redirect count, and SHALL cache fetched actors/keys with a bounded TTL.
46 +
47 +#### Scenario: Non-allowlisted host is refused
48 +
49 +- **WHEN** the system would fetch an actor from a host not on the peer allowlist
50 +- **THEN** the fetch is refused and no request is made to that host
51 +
52 +#### Scenario: Private address target is blocked
53 +
54 +- **WHEN** a remote actor `id` or `keyId` resolves to a private/loopback/link-local IP
55 +- **THEN** the system blocks the fetch
56 +
57 +### Requirement: Inbox receipt is idempotent
58 +
59 +The system SHALL record the `id` of each accepted inbound activity and SHALL ignore a redelivery of
60 +an already-processed activity, so duplicate or replayed deliveries have no additional effect.
61 +
62 +#### Scenario: Duplicate delivery is a no-op
63 +
64 +- **WHEN** the same activity `id` is delivered twice
65 +- **THEN** the system processes it once and acknowledges the duplicate without reprocessing
66 +
67 +### Requirement: Outbound delivery is queued and retried
68 +
69 +The system SHALL persist each outbound activity delivery (target inbox, payload, attempt count, next
70 +attempt time, state) and SHALL drain the queue with a background worker, retrying failed deliveries
71 +with exponential backoff up to a maximum, after which the delivery is marked failed/dead-lettered.
72 +
73 +#### Scenario: Delivery survives a restart
74 +
75 +- **WHEN** an activity is enqueued for delivery and the service restarts before it is sent
76 +- **THEN** the worker still delivers it after restart
77 +
78 +#### Scenario: Unreachable peer is retried then dead-lettered
79 +
80 +- **WHEN** a target inbox is unreachable
81 +- **THEN** the system retries with increasing backoff and, after the maximum attempts, marks the
82 + delivery failed without blocking other deliveries
83 +
84 +### Requirement: Federation is disabled by default and bounded by an allowlist
85 +
86 +The system SHALL treat `gitshark.federation.enabled` as false unless explicitly set, and when
87 +enabled SHALL accept and send federation traffic only to/from hosts on
88 +`gitshark.federation.peer-allowlist` (an empty allowlist denies all remote peers).
89 +
90 +#### Scenario: Disabled federation serves no federation endpoints
91 +
92 +- **WHEN** federation is disabled
93 +- **THEN** inbox/outbox/actor/WebFinger federation endpoints are not served (or respond `404`)
94 +
95 +#### Scenario: Off-allowlist peer is rejected
96 +
97 +- **WHEN** an inbox POST arrives from a host not on the allowlist
98 +- **THEN** the system rejects it

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog