π¦ (openspec): Archive add-forgefed-federation and sync federation specs
Changes
20 files changed, +921 -601
DELETE
openspec/changes/add-forgefed-federation/.openspec.yaml
+0 -2
@@ -1,2 +0,0 @@
1
-schema: spec-driven
2
-created: 2026-06-21
DELETE
openspec/changes/add-forgefed-federation/design.md
+0 -130
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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