Eventually-decentralized project hosting and management platform

[[ 🗃 ^WvWbo vervis ]] :: [📥 Inbox] [📤 Outbox] [🐤 Followers] [🤝 Collaborators] [🛠 Changes]

Clone

HTTPS: darcs clone https://vervis.peers.community/repos/WvWbo

SSH: darcs clone USERNAME@vervis.peers.community:WvWbo

Tags

TODO

FEDERATION.md

ForgeFed/ActivityPub Federation in Vervis

At the time of writing, here’s the current status of federation implemented in Vervis.

Summary:

For more details, read below.

Federation triggered by regular UI

The ticket comment UI allows to see tickets and comments, and if you’re logged in, you can post new comments. If you wish to post a comment on a ticket hosted on another server, not the one on which your account is hosted, see the dedicated federation pages listed below.

GET endpoints

GET /publish

A page where you can write and publish a ticket comment, either on a local ticket (i.e. a ticket on a project hosted on the same server as your account) or on a remote ticket (i.e. a ticket on a project hosted on some other server).

GET /inbox

A test page that displays received activities and the result of their processing.

GET /s/joe/inbox

A page that displays your personal inbox. It should list all ticket comments on projects you’ve created and and ticket comments on tickets you previously commented on.

GET /s/joe/outbox

A page that displays your personal outbox. It should list all the activities you’re published, all ticket comments you’ve made.

POST endpoints

POST /s/joe/outbox

Personal endpoint for publishing ticket comments. When you submit the form in the /publish page, this is where it is sent. In the future you’ll be able to see the content of your outbox, and other people will be able to see the public items in your outbox.

You can access this endpoint without using the /publish page, but Vervis doesn’t have OAuth2 support yet, so you’ll need to log in first and grab the cookie, and send it along with the request.

POST /s/joe/inbox

Personal endpoint to which other servers deliver ticket comments for you to see. These are comments on tickets on which you previously commented, and thus automatically became a follower of thosr tickets.

POST /s/joe/p/proj/inbox

Per-project inbox, to which projects receive ticket comments from other servers. If someone on another server publishes a comment on your project, then your project will receive the comment at this endpoint and the comment will be displayed when you visit the ticket page.

Spec

Federation in Vervis is done using ActivityPub. Below comes a description of the details that aren’t already common on the Fediverse. The details are written informally in the form of short simple proposals.

(A) Authentication

Vervis uses HTTP Signatures to authenticate messages received in inboxes. The Host, (request-target), Date and Digest headers are required to be present and used in the signature, and the Digest header must be verified by computing the hash of the request body. Other headers may need signing too, as specified in the proposals below.

The publicKeyPem field maps to the PEM encoding of the key. The PEM encoding contains not just the key itself, but also a code specifying the key type. The Fediverse de-facto standard is RSA, more precisely PKCS#1 v1.5, and used with the SHA-256 hash algorithm. This is often referred to as RSA-SHA256.

(1) Actor key(s) in a separate document

Allow an actor’s signing key to be a separate document, rather than embedded in the actor document. In Vervis, the use of that is for server-scope keys (see proposal below), but otherwise, an embedded key is just as good.

GET /users/aviva/keys/key1

{ "@context":     "https://w3id.org/security/v1"
, "@id":          "https://example.dev/users/aviva/keys/key1"
, "@type":        "Key"
, "owner":        "https://example.dev/users/aviva"
, "publicKeyPem": "-----BEGIN PUBLIC KEY----- ..."
}

GET /users/aviva

{ "@context":
    [ "https://www.w3.org/ns/activitystreams"
    , "https://w3id.org/security/v1"
    ]
, "id":                "https://example.dev/users/aviva"
, "type":              "Person"
, "preferredUsername": "aviva"
, "name":              "Aviva"
, "inbox":             "https://example.dev/users/aviva/inbox"
, "outbox":            "https://example.dev/users/aviva/outbox"
, "publicKey":         "https://example.dev/users/aviva/keys/key1"
}

Authentication requirements:

(2) Multiple actor keys

Allow an actor to specify more than one key, or no key at all. This means that when you examine the owner actor of the key, you verify the actor links back to the key by checking that the key is listed among the actor’s keys (instead of requiring/expecting only a single key to be specified by the actor).

The reason this is used in Vervis is for key rotation using a pair of server-cope keys (see proposal below).

When used along with proposal A.1, each key may be either embedded in the document, or a URI specifying the ID of a key defined in a separate document.

Actors that never need to post activities can simply not specify any keys at all.

GET /users/aviva

{ "@context":
    [ "https://www.w3.org/ns/activitystreams"
    , "https://w3id.org/security/v1"
    ]
, "id":                "https://example.dev/users/aviva"
, "type":              "Person"
, "preferredUsername": "aviva"
, "name":              "Aviva"
, "inbox":             "https://example.dev/users/aviva/inbox"
, "outbox":            "https://example.dev/users/aviva/outbox"
, "publicKey":
    [ { "id":           "https://example.dev/users/aviva#main-key"
      , "type":         "Key"
      , "owner":        "https://example.dev/users/aviva"
      , "publicKeyPem": "-----BEGIN PUBLIC KEY----- ..."
      }
    , "https://example.dev/users/aviva/extra-keys/extra-key1"
    , "https://example.dev/users/aviva/extra-keys/extra-key2"
    ]
}

(3) Server-scope actor key

Allows to have actor keys that can be used to sign (and verify) activities of any actor on the server, not limited to any specific actor. That allows to have some small constant number of keys on the server, which is very easy to manage and makes key rotations very cheap. It also saves storage of many local and remote actor keys.

In the common Fediverse situation, there’s a separate key for each actor, but all of these actor keys are managed by a single entity, the server. The signatures aren’t made on users’ devices using private keys they keep to themselves. They’re made by the server, using private keys the server generates.

Server-scope keys are made by the server too. The server makes the signatures, using a private key it generates and maintains. The server is the owner of the key, and a part of the signed message is the ID of the actor on whose behalf the message is being sent. Since the actor isn’t specified by the key, the actor ID is instead placed in a HTTP header. And the actor still has to list the key under publicKey as usual.

GET /key1

{ "@context":
    [ "https://w3id.org/security/v1"
    , { "isShared": "https://angeley.es/as2-ext#isShared"
      }
    ]
, "@id":          "https://example.dev/key1"
, "@type":        "Key"
, "owner":        "https://example.dev"
, "isShared":     true
, "publicKeyPem": "-----BEGIN PUBLIC KEY----- ..."
}

GET /users/aviva

{ "@context":
    [ "https://www.w3.org/ns/activitystreams"
    , "https://w3id.org/security/v1"
    ]
, "id":                "https://example.dev/users/aviva"
, "type":              "Person"
, "preferredUsername": "aviva"
, "name":              "Aviva"
, "inbox":             "https://example.dev/users/aviva/inbox"
, "outbox":            "https://example.dev/users/aviva/outbox"
, "publicKey":
    [ { "id":           "https://example.dev/users/aviva#main-key"
      , "type":         "Key"
      , "owner":        "https://example.dev/users/aviva"
      , "publicKeyPem": "-----BEGIN PUBLIC KEY----- ..."
      }
    , "https://example.dev/users/aviva/extra-keys/extra-key1"
    , "https://example.dev/users/aviva/extra-keys/extra-key2"
    , "https://example.dev/key1"
    ]
}

Requirements for a server-scope key:

Requirements for authentication using a server-scope key:

(4) Actor key expiration and revocation

Allow to improve the secure handling of signing keys by supporting expiration and revocation. Expiration means the key specifies a time at which it stops being valid, and once that time comes, signatures made by that key are considered invalid. Revocation similary means the key specifies a time at which it stops being valid.

GET /users/aviva/keys/key1

{ "@context":     "https://w3id.org/security/v1"
, "@id":          "https://example.dev/users/aviva/keys/key1"
, "@type":        "Key"
, "owner":        "https://example.dev/users/aviva"
, "created":      "2019-01-13T11:00:00+0000"
, "expires":      "2021-01-13T11:00:00+0000"
, "publicKeyPem": "-----BEGIN PUBLIC KEY----- ..."
}

Requirement: When verifying a signature, compare expires and revoked, if one of them or both of them are present, to the current time. If at least one of the 2 times is the current time or earlier, then consider the signature invalid. If using a cached version of the key, try to HTTP GET the key and try to authenticate once more, because it’s possible the key has been replaced with a new valid one.

(5) Ed25519 actor keys

Allows actor keys to be Ed25519 keys, by allowing the publicKeyPem field to simply contain a PEM encoded Ed25519 public key. The HTTP Signatures draft lists more algorithms; we could support them too. This proposal just suggests that we all start supporting Ed25519 in addition to RSA.

(6) HTTP Signature draft 11

The draft linked above, from April 2019, makes some changes and recommendations. This proposal suggests we adopt them:

(7) Key rotation using a pair of server-scope keys

Allows to easily and computationally-cheaply perform periodic key rotation.

Rationale:

If you deliver an activity and then rotate the key, the target servers will want to fetch the old key to verify your signatures, but, the old key has been replaced, so they will fail to authenticate your requests. When using per-actor keys, it’s possible to try waiting for a time the user is inactive (which is hopefully common because most people probably sleep for a few hours every day), and use that as a safer chance to rotate the key. During the quiet time, other servers will have had enough time to process their activity inbox queues, and by the time we rotate, nobody will want the old key anymore.

The weakness of that solution is that:

The proposal:

That way, when one of the keys is rotated, the other key is still available for another hour and other servers are able to use it to verify the signatures we sent. There’s no need to wait for users to be inactive, and it’s very cheap: Rotate 1 key per hour. Especially if that key is Ed25519.

(B) ActivityPub

The following proposals are federation features in Vervis, or plans and ideas not implemented yet, but they aren’t specific to forges, and other kinds of servers can benefit from them just as much.

(1) Non-actor audience

In C2S, the client sends the server an activity (or an object to be wrapped in a Create) that includes addressing properties, and the server delivers the activity to the listed recipients. The recipients may be listed as URIs or as embedded objects, and the audience can be anything, any Object, according to the AS2 vocabulary spec. Not limited to actors or collections. However, there are 2 kinds of recipients:

If the server gets just a list of URIs, some of which are on other servers, it has to HTTP GET them, and find out that some are actors and some are collections (or other non-actor objects). It may also do caching, remembering remote actors and collections in its database to avoid HTTP GETing them every time, but even then, it involves the initial GET where it fetches them and remembers in the DB.

Observation: Very possibly, the client is aware which recipients are actors and which aren’t. Especially when the non-actors are collections. So, the client can hint the server about that, so that the server doesn’t even need to do the initial GET for recipients already known to be non-actors.

There are 2 ways specified below, for making that hint. One is what Vervis currently does, and the other is perhaps a better way I’d like to propose as an alternative, and possibly switch Vervis to that better way.

What Vervis currently does is to use a custom nonActors property, which lists recipients. The client uses that property to provide a list of recipients that are known to be non-actors. For example if the to property is [x,y,z] and the nonActors field is [y], then the server can skip trying to GET the y recipient, and attempt delivery only to x and z.

Another way, which is perhaps better and I’d like to propose, is to allow the client to specify the type of the recipient in the addressing properties themselves. For example, instead of this:

[ "https://example.dev/users/martin"
, "https://example.dev/users/martin/followers"
]

We could do this:

[ { "id":   "https://example.dev/users/martin"
  , "type": "Person"
  }
, { "id":   "https://example.dev/users/martin/followers"
  , "type": "Collection"
  }
]

The problem is:

Since this is just a hint, and it’s C2S where the client and server possibly speak the same custom properties, these problems don’t make the hint useless, but they still make this approach inferior in achieving its goal, than the custom nonActors property. The reason I propose it is that it uses object’s types and doesn’t require any custom property. The reason Vervis doesn’t use it is that I don’t see a clear way to really in practice tell actors from non-actors. It also requires more computation and coding, to figure out which things are subclasses of some actor type, or collection type, and if that’s required then RDF inference is required, therefore JSON-LD processing is required. While in the nonActors approach, which maybe feels more like a trick, it’s as simple as computing set/list difference, which is generally trivial to do.

(2) Authenticated inbox forwarding

When you receive an activity from another server, by some actor A, you want to have some confidence that the activity was really published by actor A, and not by someone pretending to be actor A, or just sending you spam attributed to random people. This is done as follows:

However in some cases, such as in ForgeFed, an activity is delivered to you indirectly, by someone who isn’t the author. Specifically, there’s a mechanism in ActivityPub called inbox forwarding, in which a server receives an activity at an inbox, and delivers it further to more actors. For example, in ForgeFed, inbox forwarding is used to allow actors to address activities to collections managed by other servers, and those servers dereference the collections and forward the activity to the collection member actors.

This proposal suggests a way to authenticate such inbox-forwarded activities.

The concept is as follows: If Aviva sends Luke an activity, and she’d like him to forward it, in the HTTP POST request to his inbox, she includes an additional signature, in addition to the regular one. Luke uses the regular signature to verify the sender is really Aviva. The additional signature, he sends along when he forwards the activity, and the recipients use it to verify that:

In addition, the additional signature can be thought of as a request to forward the activity.

The technical details:

(3) Non-announced following

(4) Object nesting depth

(5) Object capability authorization tokens

Allows actors to delegate resource access to other actors, by sending them an authorization token. There are many kinds of authorization tokens, and many of them are good relevant candidates here, for example:

This proposal, however, describes the current implementation in Vervis, which uses a simple HMAC to authenticate the authorization token. Vervis on purpose uses a minimal approach, so that it’s easy to keep track of what its minimal needs really are. It’s totally possible and acceptable though, that this proposal switches to a standard auth token format such as the ones listed above. Until this proposal gets feedback and discussion, it describes the minimal HMAC approach.

Aviva manages a yoga school. Luke is a new yoga teacher in the school, and Aviva would like to give him access to open and lock all the rooms in the school building. Aviva posts a Delegate activity to her server:

{ "@context":
    [ "https://www.w3.org/ns/activitystreams"
    , { "ext":      "https://angeley.es/as2-ext#"
      , "Delegate": "ext:Delegate"
      , "Role":     "ext:Role"
      }
    ]
, "type":    "Delegate"
, "to":
    [ "https://meditation.space/users/luke"
    , "https://yoga.dev/school-staff"
    ]
, "target":  "https://meditation.space/users/luke"
, "context": "https://yoga.dev/places/school-building"
, "object":
    { "id":   "https://yoga.dev/roles/teacher"
    , "type": "Role"
    }
}

Aviva’s server assigns an ID to the activity, and also attaches a cryptographic proof. When Luke will later try to open doors in the school, the proof will be used to validate his authorization token. The proof field maps to a Base64 encoding of the HMAC-SHA256 of the activity’s ID, where the key used for the HMAC is a secret key the server holds.

{ "@context":
    [ "https://www.w3.org/ns/activitystreams"
    , { "ext":      "https://angeley.es/as2-ext#"
      , "Delegate": "ext:Delegate"
      , "Role":     "ext:Role"
      }
    ]
, "id":      "https://yoga.dev/users/aviva/outbox/m10d6"
, "type":    "Delegate"
, "to":
    [ "https://meditation.space/users/luke"
    , "https://yoga.dev/school-staff"
    ]
, "target":  "https://meditation.space/users/luke"
, "context": "https://yoga.dev/places/school-building"
, "object":
    { "id":   "https://yoga.dev/roles/teacher"
    , "type": "Role"
    }
, "proof":   "bDMCcPFntgpMoEG6SSFkXCBRm2K96h0ecFsbr11hFx0="
}

Later, when Luke wants to open a door, he publishes an activity and attaches the proof field. Aviva’s server then:

(6) Managing actor

Allows an object to specify which actor manages it. For example, if you’d like to send an Update activity, or some other activity that targets or modifies some object, but that object isn’t an actor, how do you know to which actor to send it? This proposal proposes to have a dedicated property for this purpose, independent of any domain-specific vocabulary or extension.

The current working name for this property is managedBy.

(7) Events collection

Defines a standard property to provide a collection of activities related to a given object.

Suppose Aviva is writing a story, and publishing its chapters as ActivityPub activities. Aviva is an actor, with an inbox and with an outbox, but the chapters aren’t actors. She publishes them using Create activities, in which the objects are of type Chapter or something like that. So, when Aviva publishes a chapter, it appears in her outbox.

A while later, Luke joins her story writing project, and he writes some chapters too. When he writes a chapter, he publishes it and delivers to Aviva’s inbox.

From Aviva’s point of view, her story’s activities exist in 2 places:

If we wanted to get a list of all the activities and changes to the story, how would we do that? If the story were an actor, we could deliver everything to its inbox, and then its inbox would reflect all the events and changes. But since the story isn’t an actor, there’s no obvious place for this. We’d have to somehow get a filtered view of Aviva’s outbox and a filtered view of Aviva’s inbox for this. And the latter is especially problematic, because inboxes are generally private.

This proposal suggests a property named history, which maps to an OrderedCollection of the activities related to the object. That way, even objects that aren’t stand-alone and aren’t actors can provide a stream of updates.

There are various properties that typically form a tree or graph structure when recursively traversed. And often a client may wish to fetch the entire hierarchy. For example, there’s the AS2 replies property. The AS2 spec says it should list objects that are responses. But should/can that include indirect replies, i.e. objects that are replies to replies, or should only direct replies be listed, i.e. replies is the inverse property of inReplyTo?

In ForgeFed there’s similarly a dependsOn property for listing a ticket’s dependent tickets, and the question arises there too: Provide a flat list containing the whole transitive closure of dependent tickets, i.e. dependencies of dependencies etc., or list the direct dependencies?

I suppose to some people the answer to this question is obvious, but to me it wasn’t, so I’d like to explicitly propose an answer and follow it.

replies, and dependsOn, and similar properties, map to a collection whose items are the direct related objects, not indirect transitively-related ancestors of descendants. So, replies is the inverse property of inReplyTo, and if object A lists object B under replies, then the inReplyTo field of object B should be pointing back to A.

It’s still possible for object B to have its own replies, of course, forming a tree/graph of discussion (and dependsOn forming a graph of ticket dependencies). Whether or not those nested objects forming a tree/graph are provided, is a separate question. See proposal B.4.

(C) ForgeFed

(1) Actors

How to decide which types of objects are actors and which aren’t?

The proposal here is that the following types be actors:

And other types such as these not be actors:

The lists above are just an example of the proposed rule for determining which objects should be actors and which not. It’s not necessarily always obvious, but the proposed guideline is:

Examples:

(2) Authorization and roles

(3) Comments

Comments are Note objects, published using the Create activity. Requirements, suggestions and details:

GET /luke/outbox/A0O8l

{
    "@context": "https://www.w3.org/ns/activitystreams",
    "id": "https://dev.federated.coop/luke/outbox/A0O8l",
    "type": "Create",
    "to": [
        "https://dev.federated.coop/luke/text-adventure",
        "https://dev.federated.coop/luke/text-adventure/followers",
        "https://dev.federated.coop/luke/text-adventure/issues/113/followers",
        "https://dev.federated.coop/luke/text-adventure/issues/113/team"
    ],
    "actor": "https://dev.federated.coop/luke",
    "object": {
        "id": "https://dev.federated.coop/luke/comments/L0dRp",
        "type": "Note",
        "attributedTo": "https://dev.federated.coop/luke",
        "context": "https://dev.federated.coop/luke/text-adventure/issues/113",
        "published": "2019-05-26T11:56:50.024267645Z",
        "to": [
            "https://dev.federated.coop/luke/text-adventure",
            "https://dev.federated.coop/luke/text-adventure/followers",
            "https://dev.federated.coop/luke/text-adventure/issues/113/followers",
            "https://dev.federated.coop/luke/text-adventure/issues/113/team"
        ],
        "content": "That's such a wonderful idea!",
        "inReplyTo": "https://poetry.space/aviva/comments/xN82v"
    }
}

TODO:

(4) Tickets

While comments are published and hosted by the actors who write them, a project’s tickets are hosted by the project. Actors may still host their original copy of a ticket. I suppose in general we could allow tickets to be published first, and then offered in a separate activity, and we could allow projects to list tickets hosted on other servers. TODO discuss these two things. In Vervis, at the time of writing, a ticket is offered without a prior publishing activity, and projects all host their tickets.

A ticket is published using an Offer activity. The author actor may address the project’s followers, but the server may decide not to deliver to them (or perhaps deliver to them only if the ticket is accepted). In Vervis, project followers get delivered to. Also in Vervis, a ticket’s deps and rdeps can only be tickets under the same project, but this restriction will hopefully be relaxed in the future.

The Offer activity looks more-or-less like this:

GET /aviva/activities/c8lrd0

{ "@context":
    [ "https://www.w3.org/ns/activitystreams"
    , { "forge":     "https://forgefed.peers.community/ns#"
      , "Ticket":    "forge:Ticket"
      , "isResolved": "forge:isResolved"
      , "dependsOn":
          { "@id":   "forge:dependsOn"
          , "@type": "@id"
          }
      }
    ]
, "id":           "https://https://poetry.space/aviva/activities/c8lrd0"
, "type":         "Offer"
, "to":
    [ "https://poetry.space/aviva/followers"
    , "https://dev.federated.coop/luke/text-adventure"
    , "https://dev.federated.coop/luke/text-adventure/team"
    , "https://dev.federated.coop/luke/text-adventure/followers"
    ]
, "summary":      "<p>Aviva offered a ticket to project text-adventure.</p>"
, "target":       "https://dev.federated.coop/luke/text-adventure"
, "object":
    { "type":         "Ticket"
    , "attributedTo": "https://poetry.space/aviva"
    , "published":    "2019-02-17T11:31:33Z"
    , "summary":      "<p>Game crashes when tasting the coconut cream</p>"
    , "content":      "..."
    , "mediaType":    "text/html"
    , "source":
        { "content":   "..."
        , "mediaType": "text/markdown"
        }
    , "isResolved":   false
    , "dependsOn":
        [ "https://dev.federated.coop/luke/text-adventure/issues/106"
        , "https://dev.community/jerry/text-game-engine/issues/1219"
        ]
    }
}

If the ticket is accepted (which may happen automatically or manually, in Vervis currently always happens automatically), the project’s server gives it an ID and hosts a copy in the project’s ticket tracker. The Ticket object may look like this:

GET /luke/text-adventure/issues/113

{ "@context":
    [ "https://www.w3.org/ns/activitystreams"
    , { "forge":     "https://forgefed.peers.community/ns#"
      , "ext":       "https://peers.community/as2-ext#"
      , "Ticket":    "forge:Ticket"
      , "assignedTo":
          { "@id":   "forge:assignedTo"
          , "@type": "@id"
          }
      , "isResolved": "forge:isResolved"
      , "participants":
          { "@id":   "ext:participants"
          , "@type": "@id"
          }
      , "team":
          { "@id":   "ext:team"
          , "@type": "@id"
          }
      , "dependsOn":
          { "@id":   "forge:dependsOn"
          , "@type": "@id"
          }
      , "dependedBy":
          { "@id":   "forge:dependedBy"
          , "@type": "@id"
          }
      , "history":
          { "@id":   "ext:history"
          , "@type": "@id"
          }
      }
    ]
, "id":           "https://dev.federated.coop/luke/text-adventure/issues/113"
, "type":         "Ticket"
, "attributedTo": "https://poetry.space/aviva"
, "published":    "2019-02-17T11:31:33Z"
, "updated":      "2019-06-01T12:30:36Z"
, "context":      "https://dev.federated.coop/luke/text-adventure"
, "name":         "#113"
, "summary":      "<p>Game crashes when tasting the coconut cream</p>"
, "content":      "..."
, "mediaType":    "text/html"
, "source":
    { "content":   "..."
    , "mediaType": "text/markdown"
    }
, "replies":
    [ https://dev.federated.coop/users/luke/posts/vr7mnt9
    , https://dev.community/jerry/outbox/n3y0rk
    ]
, "assignedTo":   "https://dev.community/jerry"
, "isResolved":   false
, "participants": "https://dev.federated.coop/luke/text-adventure/issues/113/participants"
, "team":         "https://dev.federated.coop/luke/text-adventure/issues/113/team"
, "dependsOn":
    [ "https://dev.federated.coop/luke/text-adventure/issues/106"
    , "https://dev.community/jerry/text-game-engine/issues/1219"
    ]
, "dependedBy": "https://dev.federated.coop/luke/text-adventure/issues/87"
, "history":
    [ "https://https://poetry.space/aviva/activities/c8lrd0"
    , "https://dev.federated.coop/luke/text-adventure/outbox/b3r1shv4"
    ]
}

The Accept activity can be sent automatically by the Project actor, or manually by a Person in the project team. It has object set to the URI of the Offer, and result set to the URI of the newly created Ticket.

{
    "summary": "<p>fr33's ticket accepted by project ./s/fr33/p/sandbox: This ticket is open</p>",
    "@context": [
        "https://www.w3.org/ns/activitystreams",
        "https://forgefed.peers.community/ns",
        "https://angeley.es/as2-ext"
    ],
    "to": [
        "https://forge.angeley.es/s/fr33",
        "https://forge.angeley.es/s/fr33/p/sandbox/team",
        "https://forge.angeley.es/s/fr33/p/sandbox/followers"
    ],
    "actor": "https://forge.angeley.es/s/fr33/p/sandbox",
    "result": "https://forge.angeley.es/s/fr33/p/sandbox/t/6",
    "object": "https://forge.angeley.es/s/fr33/outbox/wl5Yl",
    "id": "https://forge.angeley.es/s/fr33/p/sandbox/outbox/r07JE",
    "type": "Accept"
}

TODO turn replies and history into URIS pointing to separate Collections

TODO replies and depends (ForgeFed #12)

TODO content/source and media types (ForgeFed #11)

(5) Patches

(6) Merge requests

(7) Commits

(8) Forks

(9) SSH keys

(10) Pushes

(11) Avatars

Proposal:

[See repo JSON]