Merge pull request #35 from versia-pub/feat/v0.5

Release Versia Protocol 0.5
This commit is contained in:
Gaspard Wierzbinski 2025-01-17 15:53:26 +01:00 committed by GitHub
commit 13132e9745
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 1293 additions and 348 deletions

8
.github/workflows/mirror.yml vendored Normal file
View file

@ -0,0 +1,8 @@
name: Mirror to Codeberg
on: [push]
jobs:
mirror:
name: Mirror
uses: versia-pub/.github/.github/workflows/mirror.yml@main
secrets: inherit

View file

@ -6,9 +6,35 @@ export const metadata = {
# Changelog # Changelog
This page lists changes since Working Draft 03. {{ className: 'lead' }} This page lists changes since Working Draft 3. {{ className: 'lead' }}
## Since WD 03 ## Since WD 4
- Removed URI from [Report](/extensions/reports), and replaced `reason` with `tags`.
- Docs now use the term "Transient Entity" where appropriate.
- Mandated Unix-style `\n` line endings in all text fields.
- Renamed the following headers, as per [RFC 6648](https://tools.ietf.org/html/rfc6648):
- `X-Signature` to `Versia-Signature`
- `X-Signed-By` to `Versia-Signed-By`
- Removed the nonce from the [signature system](/signatures), replaced with `Versia-Signed-At` (timestamps).
- Standardize rate limits with [IETF draft draft-polli-ratelimit-headers-02](https://www.ietf.org/archive/id/draft-polli-ratelimit-headers-02.html).
- Properly documented [Group](/extensions/groups) federation and subscribing.
- Moved [Groups](/extensions/groups) to an extension, as they were getting too complex for the core protocol.
- The [Note](/entities/note) `group` field documentation has been updated to reflect this.
- Added [Versia Links](/links).
- Switched from ISO 8601 to [RFC 3339](https://datatracker.ietf.org/doc/html/rfc3339) for timestamps.
- In most cases, the two are interchangeable, but RFC 3339 is more strict. Most implementations should not need to change anything.
- Add optional `$schema` field to [Entities](/entities).
- Added [Interaction Controls Extensions](/extensions/interaction-controls)
- Added [URI Collections](/structures/collection#uri-collection)
- Changed all Collections that can contain remote entities to use URI Collections (eg. [User](/entities/user) `collections.followers`).
- Add `collections` field to [Notes](/entities/note).
- [Likes Extension](/extensions/likes), [Reactions Extension](/extensions/reactions) and [Share Extension](/extensions/share) now use this field, instead of a custom field in `extensions`.
- Allowed uppercase characters in [User](/entities/user) `username`.
- These are now case-insensitive.
- Added `timezone` field to [Vanity Extension](/extensions/vanity).
## Since WD 3
- Rewrote the signature system from scratch to be simpler and not depend on dates. - Rewrote the signature system from scratch to be simpler and not depend on dates.
- Moved Likes and Dislikes to an extension. - Moved Likes and Dislikes to an extension.

View file

@ -5,7 +5,7 @@ export const metadata = {
# Delete # Delete
Signals the deletion of an entity. {{ className: 'lead' }} Signals the deletion of an entity. It is a [**Transient Entity**](/entities#transient-entities). {{ className: 'lead' }}
## Authorization ## Authorization
@ -19,9 +19,9 @@ Having the authorization is defined as:
<Row> <Row>
<Col> <Col>
<Properties> <Properties name="Delete">
<Property name="uri" type="null" required={false}> <Property name="uri" type="null" required={false}>
This entity does not have a URI. This is a [**Transient Entity**](/entities#transient-entities) and does not have a URI.
</Property> </Property>
<Property name="author" type="URI | null" required={true} typeLink="/types#uri"> <Property name="author" type="URI | null" required={true} typeLink="/types#uri">
URI of the `User` who is deleting the entity. [Can be set to `null` to represent the instance](/entities/instance-metadata#the-null-author). URI of the `User` who is deleting the entity. [Can be set to `null` to represent the instance](/entities/instance-metadata#the-null-author).

View file

@ -13,9 +13,9 @@ export const metadata = {
<Row> <Row>
<Col> <Col>
<Properties> <Properties name="FollowAccept">
<Property name="uri" type="null" required={false}> <Property name="uri" type="null" required={false}>
This entity does not have a URI. This is a [**Transient Entity**](/entities#transient-entities) and does not have a URI.
</Property> </Property>
<Property name="author" type="URI" required={true} typeLink="/types#uri"> <Property name="author" type="URI" required={true} typeLink="/types#uri">
URI of the `User` considered the 'followee', i.e. the user who is being followed. URI of the `User` considered the 'followee', i.e. the user who is being followed.

View file

@ -23,9 +23,9 @@ But it can also be used when Bob is already following Alice, in the case that:
<Row> <Row>
<Col> <Col>
<Properties> <Properties name="FollowReject">
<Property name="uri" type="null" required={false}> <Property name="uri" type="null" required={false}>
This entity does not have a URI. This is a [**Transient Entity**](/entities#transient-entities) and does not have a URI.
</Property> </Property>
<Property name="author" type="URI" required={true} typeLink="/types#uri"> <Property name="author" type="URI" required={true} typeLink="/types#uri">
URI of the `User` considered the 'followee', i.e. the user who is being followed. URI of the `User` considered the 'followee', i.e. the user who is being followed.

View file

@ -55,9 +55,9 @@ Once a follow relationship is established, the **followee**'s instance should se
<Row> <Row>
<Col> <Col>
<Properties> <Properties name="Follow">
<Property name="uri" type="null" required={false}> <Property name="uri" type="null" required={false}>
This entity does not have a URI. This is a [**Transient Entity**](/entities#transient-entities) and does not have a URI.
</Property> </Property>
<Property name="author" type="URI" required={true} typeLink="/types#uri"> <Property name="author" type="URI" required={true} typeLink="/types#uri">
URI of the `User` considered the 'follower'. URI of the `User` considered the 'follower'.

View file

@ -1,58 +0,0 @@
export const metadata = {
title: 'Groups',
description: 'Groups are a way to organize users and notes into communities.'
}
# Groups
Groups are a way to organize users and notes into communities. They can be used for any purpose, such as forums, blogs, image galleries, video sharing, audio sharing, and messaging. They are similar to Discord's channels or Matrix's rooms. {{ className: 'lead' }}
Refer to [Note](/entities/note#entity-definition)'s `group` property for how notes can be associated with groups.
## Entity Definition
<Row>
<Col>
<Properties>
<Property name="name" type="ContentFormat" required={false} typeLink="/structures/content-format">
Group name/title.
Text only (`text/plain`, `text/html`, etc).
</Property>
<Property name="description" type="ContentFormat" required={false} typeLink="/structures/content-format">
Short description of the group's contents and purpose.
Text only (`text/plain`, `text/html`, etc).
</Property>
<Property name="members" type="URI" required={true} typeLink="/types#uri">
URI of the group's members list. [Collection](/structures/collection) of [Users](/entities/user).
</Property>
<Property name="notes" type="URI" required={false} typeLink="/types#uri">
URI of the group's associated notes. [Collection](/structures/collection) of [Notes](/entities/note).
</Property>
</Properties>
</Col>
<Col sticky>
```jsonc {{ title: "Example Group" }}
{
"type": "Group",
"id": "ed480922-b095-4f09-9da5-c995be8f5960",
"uri": "https://example.com/groups/ed480922-b095-4f09-9da5-c995be8f5960",
"name": {
"text/html": {
"content": "The <strong>Woozy</strong> fan club"
}
},
"description": {
"text/plain": {
"content": "A group for fans of the Woozy emoji."
}
},
"members": "https://example.com/groups/ed480922-b095-4f09-9da5-c995be8f5960/members",
}
```
</Col>
</Row>

View file

@ -17,7 +17,7 @@ Check the entity's documentation page to see if it supports this (it will be not
<Row> <Row>
<Col> <Col>
<Properties> <Properties name="InstanceMetadata">
<Property name="id" type="null"> <Property name="id" type="null">
This entity does not have an ID. This entity does not have an ID.
</Property> </Property>
@ -74,7 +74,7 @@ Check the entity's documentation page to see if it supports this (it will be not
``` ```
- `algorithm`: Algorithm used for the public key. Can only be `ed25519` for now. - `algorithm`: Algorithm used for the public key. Can only be `ed25519` for now.
- `key`: Instance public key, in SPKI-encoded base64 (from raw bytes, not a PEM format). - `key`: Instance public key, in [SPKI-encoded base64](/signatures#exporting-the-public-key).
</Property> </Property>
<Property name="moderators" type="URI" required={false}> <Property name="moderators" type="URI" required={false}>
URI to [Collection](/structures/collection) of instance moderators. URI to [Collection](/structures/collection) of instance moderators.
@ -112,7 +112,8 @@ Check the entity's documentation page to see if it supports this (it will be not
"compatibility": { "compatibility": {
"versions": [ "versions": [
"0.3.0", "0.3.0",
"0.4.0" "0.4.0",
"0.5.0"
], ],
"extensions": [ "extensions": [
"pub.versia:reactions", "pub.versia:reactions",

View file

@ -15,7 +15,7 @@ Notes represent a piece of content on a Versia instance. They can be posted by [
<Row> <Row>
<Col> <Col>
<Properties> <Properties name="Note">
<Property name="attachments" type="ContentFormat[]" required={false} typeLink="/structures/content-format"> <Property name="attachments" type="ContentFormat[]" required={false} typeLink="/structures/content-format">
Media attachments to the note. May be any format. **Must** be remote. Media attachments to the note. May be any format. **Must** be remote.
</Property> </Property>
@ -36,6 +36,28 @@ Notes represent a piece of content on a Versia instance. They can be posted by [
| "messaging"; // Like Discord, Element (Matrix), Signal | "messaging"; // Like Discord, Element (Matrix), Signal
``` ```
</Property> </Property>
<Property name="collections" type="NoteCollections" required={true}>
Collections related to the note. Must contain at least `replies` and `quotes`.
```typescript
type URI = string;
type NoteCollections = {
replies: URI;
quotes: URI;
// Same format as type on Extensions
[key: ExtensionsKey]: URI;
}
```
All URIs must resolve to either a [Collection](/structures/collection) or a [URI Collection](/structures/collection#uri-collection) of the appropriate entities. Extensions may add additional collections.
### Replies
All replies to this note (have this note as their `replies_to`). [URI Collection](/structures/collection#uri-collection) of [Note](/entities/note) entities.
### Quotes
All quotes of this note (have this note as their `quotes`). [URI Collection](/structures/collection#uri-collection) of [Note](/entities/note) entities.
</Property>
<Property name="content" type="ContentFormat" required={false} typeLink="/structures/content-format"> <Property name="content" type="ContentFormat" required={false} typeLink="/structures/content-format">
The content of the note. Must be text format (`text/html`, `text/markdown`, etc). Must not be remote. The content of the note. Must be text format (`text/html`, `text/markdown`, etc). Must not be remote.
</Property> </Property>
@ -51,11 +73,15 @@ Notes represent a piece of content on a Versia instance. They can be posted by [
``` ```
</Property> </Property>
<Property name="group" type="URI | &quot;public&quot; | &quot;followers&quot;" required={false} typeLink="/types#uri"> <Property name="group" type="URI | &quot;public&quot; | &quot;followers&quot;" required={false} typeLink="/types#uri">
URI of a [Group](/entities/group) that the note is only visible in, or one of the following strings: URI of a [Group](/extensions/groups) that the note is only visible in, or one of the following strings:
- `public`: The note is visible to anyone. - `public`: The note is visible to anyone.
- `followers`: The note is visible only to the author's followers. - `followers`: The note is visible only to the author's followers.
If not provided, the note is only visible to the author and those mentioned in the note. If not provided, the note is only visible to the author and those mentioned in the note. If missing, the note is only visible to any mentioned users.
<Note>
If the implementation does not support the [Groups Extension](/extensions/groups), any value other than `public` or `followers` should be treated as `null`.
</Note>
</Property> </Property>
<Property name="is_sensitive" type="boolean" required={false}> <Property name="is_sensitive" type="boolean" required={false}>
Whether the note contains "sensitive content". This can be used with `subject` as a "content warning" feature. Whether the note contains "sensitive content". This can be used with `subject` as a "content warning" feature.
@ -126,6 +152,13 @@ Notes represent a piece of content on a Versia instance. They can be posted by [
], ],
"author": "https://versia.social/users/018eb863-753f-76ff-83d6-fd590de7740a", "author": "https://versia.social/users/018eb863-753f-76ff-83d6-fd590de7740a",
"category": "microblog", "category": "microblog",
"collections": {
"replies": "https://versia.social/objects/01902e09-0f8b-72de-8ee3-9afc0cf5eae1/replies",
"quotes": "https://versia.social/objects/01902e09-0f8b-72de-8ee3-9afc0cf5eae1/quotes",
"pub.versia:likes/Likes": "https://versia.social/objects/01902e09-0f8b-72de-8ee3-9afc0cf5eae1/likes",
"pub.versia:likes/Dislikes": "https://versia.social/objects/01902e09-0f8b-72de-8ee3-9afc0cf5eae1/dislikes",
"pub.versia:reactions/Reactions": "https://versia.social/objects/01902e09-0f8b-72de-8ee3-9afc0cf5eae1/reactions"
},
"content": { "content": {
"text/html": { "text/html": {
"content": "<p>In the next versia-fe update: account settings, finally!</p>" "content": "<p>In the next versia-fe update: account settings, finally!</p>"

View file

@ -17,15 +17,15 @@ Any field in an entity not marked as `required` may be omitted or set to `null`.
<Row> <Row>
<Col> <Col>
<Properties> <Properties name="Entity">
<Property name="id" type="string" required={true}> <Property name="id" type="string" required={true}>
Unique identifier for the entity. Must be unique within the instance. Can be any string. Max of 512 UTF-8 characters. Unique identifier for the entity. Must be unique within the instance. Can be any string. Max of 512 UTF-8 characters.
</Property> </Property>
<Property name="type" type="string" required={true}> <Property name="type" type="string" required={true}>
Type of the entity. Custom types must follow [Extension Naming](/extensions#naming). Type of the entity. Custom types must follow [Extension Naming](/extensions#naming).
</Property> </Property>
<Property name="created_at" type="ISO8601" required={true} typeLink="/types#iso-8601"> <Property name="created_at" type="RFC3339" required={true} typeLink="/types#rfc3339">
Date and time when the entity was created. Must be an [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) formatted string. Date and time when the entity was created. Must be an [RFC 3339](https://datatracker.ietf.org/doc/html/rfc3339) timestamp.
<Note> <Note>
Handling of dates that are valid but obviously incorrect (e.g. in the future) is left to the Implementation's discretion. Handling of dates that are valid but obviously incorrect (e.g. in the future) is left to the Implementation's discretion.
@ -34,7 +34,16 @@ Any field in an entity not marked as `required` may be omitted or set to `null`.
<Property name="uri" type="URI" required={true} typeLink="/types#uri"> <Property name="uri" type="URI" required={true} typeLink="/types#uri">
URI of the entity. Should be unique and resolve to the entity. Must be an absolute URI. URI of the entity. Should be unique and resolve to the entity. Must be an absolute URI.
**Some entity types may not need a URI. This will be specified in the entity's documentation.** <Note>
[**Transient Entities**](/entities#transient-entities) do not require a URI.
</Note>
</Property>
<Property name="$schema" type="string" required={false}>
URL of any JSON Schema that the entity adheres to.
<Note>
This is for human use only, and not to be used by either clients or servers as a way to validate the entity.
</Note>
</Property> </Property>
<Property name="extensions" type="Extensions" required={false} typeLink="/types#extensions"> <Property name="extensions" type="Extensions" required={false} typeLink="/types#extensions">
Extensions to the entity. Use this to add custom properties to the entity. Extensions to the entity. Use this to add custom properties to the entity.
@ -77,9 +86,16 @@ Any field in an entity not marked as `required` may be omitted or set to `null`.
</Col> </Col>
</Row> </Row>
## Transient Entities
Some entities are transient, meaning they do not have a URI. These entities are used for actions that do not require a permanent record, such as deletions or migrations.
Implementations **must not** rely on other implementations to store transient entities in their database.
## Serialization ## Serialization
When serialized to a string, the JSON representation of an entity should follow the following rules: When serialized to a string, the JSON representation of an entity must follow the following rules:
- Keys must be sorted lexicographically. - Keys must be sorted lexicographically.
- Should use UTF-8 encoding. - Must use UTF-8 encoding.
- Must be **signed** using the relevant [User](/entities/user)'s private key, or the [instance's private key](/entities/instance-metadata) if the entity is not associated with a particular user. - Must be **signed** using the relevant [User](/entities/user)'s private key, or the [instance's private key](/entities/instance-metadata) if the entity is not associated with a particular user.
- Must use Unix-style `\n` line endings (LF).

View file

@ -28,9 +28,9 @@ Sometimes, [Users](/entities/user) want to unsubscribe from each other to stop s
<Row> <Row>
<Col> <Col>
<Properties> <Properties name="Unfollow">
<Property name="uri" type="null" required={false}> <Property name="uri" type="null" required={false}>
This entity does not have a URI. This is a [**Transient Entity**](/entities#transient-entities) and does not have a URI.
</Property> </Property>
<Property name="author" type="URI" required={true} typeLink="/types#uri"> <Property name="author" type="URI" required={true} typeLink="/types#uri">
URI of the `User` considered the 'follower', i.e. the user who is unsubscribing from the followee. URI of the `User` considered the 'follower', i.e. the user who is unsubscribing from the followee.

View file

@ -23,7 +23,7 @@ For example:
@018ec082-0ae1-761c-b2c5-22275a611771@versia.social @018ec082-0ae1-761c-b2c5-22275a611771@versia.social
``` ```
This is similar to an email address or an ActivityPub address. This is similar to an email address or an ActivityPub address. Usernames are case-insensitive.
### Identifier ### Identifier
@ -42,7 +42,7 @@ Instance **must** be the host of the instance the user is on (hostname with opti
<Row> <Row>
<Col> <Col>
<Properties> <Properties name="User">
<Property name="avatar" type="ContentFormat" required={false} typeLink="/structures/content-format"> <Property name="avatar" type="ContentFormat" required={false} typeLink="/structures/content-format">
The user's avatar. Must be an image format (`image/*`). The user's avatar. Must be an image format (`image/*`).
</Property> </Property>
@ -65,7 +65,7 @@ Instance **must** be the host of the instance the user is on (hostname with opti
<Property name="username" type="string" required={true}> <Property name="username" type="string" required={true}>
Alpha-numeric username. Must be unique within the instance. **Must** be treated as changeable by the user. Alpha-numeric username. Must be unique within the instance. **Must** be treated as changeable by the user.
Can only contain the following characters: `a-z` (lowercase), `0-9`, `_` and `-`. Should be limited to reasonable lengths. Can only contain the following characters: `a-z`, `A-Z` (case-insensitive), `0-9`, `_` and `-`. Should be limited to reasonable lengths.
</Property> </Property>
<Property name="header" type="ContentFormat" required={false} typeLink="/structures/content-format"> <Property name="header" type="ContentFormat" required={false} typeLink="/structures/content-format">
A header image for the user's profile. Also known as a cover photo or a banner. Must be an image format (`image/*`). A header image for the user's profile. Also known as a cover photo or a banner. Must be an image format (`image/*`).
@ -74,7 +74,7 @@ Instance **must** be the host of the instance the user is on (hostname with opti
The user's public key. Must follow the [Versia Public Key](/signatures) format. `actor` may be a URI to another user's profile, in which case this key may allow the other user act on behalf of this user (see [delegation](/federation/delegation)). The user's public key. Must follow the [Versia Public Key](/signatures) format. `actor` may be a URI to another user's profile, in which case this key may allow the other user act on behalf of this user (see [delegation](/federation/delegation)).
- `algorithm`: Must be `ed25519` for now. - `algorithm`: Must be `ed25519` for now.
- `key`: The public key in SPKI-encoded base64 (from raw bytes, not a PEM format). Must be the key associated with the `actor` URI. - `key`: The public key in [SPKI-encoded base64](/signatures#exporting-the-public-key). Must be the key associated with the `actor` URI.
- `actor`: URI to a user's profile, most often the user's own profile. - `actor`: URI to a user's profile, most often the user's own profile.
```typescript ```typescript
@ -114,19 +114,19 @@ Instance **must** be the host of the instance the user is on (hostname with opti
} }
``` ```
All URIs must resolve to a [Collection](/structures/collection) of the appropriate entities. Extensions may add additional collections. All URIs must resolve to either a [Collection](/structures/collection) or a [URI Collection](/structures/collection#uri-collection) of the appropriate entities. Extensions may add additional collections.
### Outbox ### Outbox
The user's federation outbox. Refer to the [federation documentation](/federation). The user's federation outbox. Refer to the [federation documentation](/federation). [Collection](/structures/collection) of [Note](/entities/note) entities.
### Followers ### Followers
User's followers. [Collection](/structures/collection) of [User](/entities/user) entities. User's followers. [URI Collection](/structures/collection#uri-collection) of [User](/entities/user) entities.
### Following ### Following
Users that the user follows. [Collection](/structures/collection) of [User](/entities/user) entities. Users that the user follows. [URI Collection](/structures/collection#uri-collection) of [User](/entities/user) entities.
### Featured ### Featured
[Notes](/entities/note) that the user wants to feature (also known as "pin") on their profile. [Collection](/structures/collection) of [Note](/entities/note) entities. [Notes](/entities/note) that the user wants to feature (also known as "pin") on their profile. [Collection](/structures/collection) of [Note](/entities/note) entities. Only notes authored by the user can be featured.
</Property> </Property>
</Properties> </Properties>

View file

@ -11,7 +11,7 @@ The Custom Emojis extension adds support for adding personalized emojis to feder
<Row> <Row>
<Col> <Col>
<Properties> <Properties name="CustomEmoji">
<Property name="name" type="string" required={true}> <Property name="name" type="string" required={true}>
Emoji name, surrounded by identification characters (for example, colons: `:happy_face:`). Emoji name, surrounded by identification characters (for example, colons: `:happy_face:`).
@ -72,7 +72,7 @@ Custom Emojis can be added to any entity with text content. The extension ID is
<Row> <Row>
<Col> <Col>
<Properties> <Properties name="CustomEmojisExtension">
<Property name="emojis" type="CustomEmoji[]" required={true} typeLink="/extensions/custom-emoji#structure-definition"> <Property name="emojis" type="CustomEmoji[]" required={true} typeLink="/extensions/custom-emoji#structure-definition">
[Custom emojis](/extensions/custom-emoji#structure-definition) to be added to the note. [Custom emojis](/extensions/custom-emoji#structure-definition) to be added to the note.
</Property> </Property>
@ -87,6 +87,10 @@ Custom Emojis can be added to any entity with text content. The extension ID is
"type": "Note", "type": "Note",
"uri": "https://versia.social/notes/456df8ed-daf1-4062-abab-491071c7b8dd", "uri": "https://versia.social/notes/456df8ed-daf1-4062-abab-491071c7b8dd",
"created_at": "2024-04-09T01:38:51.743Z", "created_at": "2024-04-09T01:38:51.743Z",
"collections": {
"replies": "https://versia.social/notes/456df8ed-daf1-4062-abab-491071c7b8dd/replies",
"quotes": "https://versia.social/notes/456df8ed-daf1-4062-abab-491071c7b8dd/quotes"
},
"content": { "content": {
"text/plain": { "text/plain": {
"content": "Hello, world :happy_face:!" "content": "Hello, world :happy_face:!"

View file

@ -0,0 +1,272 @@
export const metadata = {
title: 'Groups Extension',
description: 'Groups are a way to organize users and notes into communities.'
}
# Groups Extension
Groups are a way to organize users and notes into communities. They can be used for any purpose, such as forums, blogs, image galleries, video sharing, audio sharing, and messaging. They are similar to Discord's channels or Matrix's rooms. {{ className: 'lead' }}
Refer to [Note](/entities/note#entity-definition)'s `group` property for how notes can be associated with groups.
## Entity Definition
<Row>
<Col>
<Properties name="Group">
<Property name="type" type="string" required={true}>
Must be `pub.versia:groups/Group`.
</Property>
<Property name="name" type="ContentFormat" required={false} typeLink="/structures/content-format">
Group name/title.
Text only (`text/plain`, `text/html`, etc).
</Property>
<Property name="description" type="ContentFormat" required={false} typeLink="/structures/content-format">
Short description of the group's contents and purpose.
Text only (`text/plain`, `text/html`, etc).
</Property>
<Property name="open" type="boolean" required={false}>
Whether the group is open to all users or requires approval to join.
<Note>
**This is meant as a UI hint** and does not guarantee that group subscriptions will be accepted or rejected.
It is similar to a [User](/entities/user)'s `manually_approves_followers` field.
</Note>
</Property>
<Property name="members" type="URI" required={true} typeLink="/types#uri">
URI of the group's members list. [URI Collection](/structures/collection#uri-collection) of [Users](/entities/user).
</Property>
<Property name="notes" type="URI" required={false} typeLink="/types#uri">
URI of the group's associated notes. [URI Collection](/structures/collection#uri-collection) of [Notes](/entities/note).
</Property>
</Properties>
</Col>
<Col sticky>
```jsonc {{ title: "Example Group" }}
{
"type": "pub.versia:groups/Group",
"id": "ed480922-b095-4f09-9da5-c995be8f5960",
"uri": "https://example.com/groups/ed480922-b095-4f09-9da5-c995be8f5960",
"name": {
"text/html": {
"content": "The <strong>Woozy</strong> fan club"
}
},
"description": {
"text/plain": {
"content": "A group for fans of the Woozy emoji."
}
},
"open": false,
"members": "https://example.com/groups/ed480922-b095-4f09-9da5-c995be8f5960/members",
}
```
</Col>
</Row>
## Subscribing to Groups
[Users](/entities/user) may "subscribe" to a Group in order to receive all [Notes](/entities/note) posted to it. The mechanism by which federation is handled is described at [the end of this document](#federation).
First, a [User](/entities/user) must send a `GroupSubscribe` activity to the group. The group will then respond with either a `GroupSubscribeAccept` or a `GroupSubscribeReject` activity.
If the group accepts the subscription, the user will receive all notes posted to the group. If the group rejects the subscription, the user will not receive any notes posted to the group.
### GroupSubscribe
Indicates that a [User](/entities/user) wishes to subscribe to a group.
<Row>
<Col>
<Properties name="GroupSubscribe">
<Property name="type" type="string" required={true}>
Must be `pub.versia:groups/Subscribe`.
</Property>
<Property name="uri" type="null" required={false}>
This is a [**Transient Entity**](/entities#transient-entities) and does not have a URI.
</Property>
<Property name="subscriber" type="URI" required={true} typeLink="/types#uri">
URI of the [User](/entities/user) subscribing to the group.
</Property>
<Property name="group" type="URI" required={true} typeLink="/types#uri">
URI of the group to subscribe to.
</Property>
</Properties>
</Col>
<Col sticky>
```jsonc {{ title: "Example GroupSubscribe" }}
{
"type": "pub.versia:groups/Subscribe",
"id": "9a7e9345-4e4a-4d5a-8301-4dbbfe777ca0",
"subscriber": "https://bob.social/users/e9277471-8aa1-4d40-a3d0-0878e818ccdc",
"group": "https://example.com/groups/ed480922-b095-4f09-9da5-c995be8f5960",
"created_at": "2021-01-01T00:00:00Z"
}
```
</Col>
</Row>
### GroupUnsubscribe
Indicates that a [User](/entities/user) wishes to unsubscribe from a group.
<Row>
<Col>
<Properties name="GroupUnsubscribe">
<Property name="type" type="string" required={true}>
Must be `pub.versia:groups/Unsubscribe`.
</Property>
<Property name="uri" type="null" required={false}>
This is a [**Transient Entity**](/entities#transient-entities) and does not have a URI.
</Property>
<Property name="subscriber" type="URI" required={true} typeLink="/types#uri">
URI of the [User](/entities/user) unsubscribing from the group.
</Property>
<Property name="group" type="URI" required={true} typeLink="/types#uri">
URI of the group to unsubscribe from.
</Property>
</Properties>
</Col>
<Col sticky>
```jsonc {{ title: "Example GroupUnsubscribe" }}
{
"type": "pub.versia:groups/Unsubscribe",
"id": "9a7e9345-4e4a-4d5a-8301-4dbbfe777ca0",
"subscriber": "https://bob.social/users/e9277471-8aa1-4d40-a3d0-0878e818ccdc",
"group": "https://example.com/groups/ed480922-b095-4f09-9da5-c995be8f5960",
"created_at": "2021-01-01T00:00:00Z"
}
```
</Col>
</Row>
### GroupSubscribeAccept
Indicates that a [Group](#entity-definition) has accepted a [User](/entities/user)'s subscription request. Should be signed by the instance hosting the group.
<Row>
<Col>
<Properties name="GroupSubscribeAccept">
<Property name="type" type="string" required={true}>
Must be `pub.versia:groups/SubscribeAccept`.
</Property>
<Property name="uri" type="null" required={false}>
This is a [**Transient Entity**](/entities#transient-entities) and does not have a URI.
</Property>
<Property name="subscriber" type="URI" required={true} typeLink="/types#uri">
URI of the [User](/entities/user) subscribing to the group.
</Property>
<Property name="group" type="URI" required={true} typeLink="/types#uri">
URI of the group that accepted the subscription.
</Property>
</Properties>
</Col>
<Col sticky>
```jsonc {{ title: "Example GroupSubscribeAccept" }}
{
"type": "pub.versia:groups/SubscribeAccept",
"id": "9a7e9345-4e4a-4d5a-8301-4dbbfe777ca0",
"subscriber": "https://bob.social/users/e9277471-8aa1-4d40-a3d0-0878e818ccdc",
"group": "https://example.com/groups/ed480922-b095-4f09-9da5-c995be8f5960",
"created_at": "2021-01-01T00:00:00Z"
}
```
</Col>
</Row>
### GroupSubscribeReject
Indicates that a [Group](#entity-definition) has rejected a [User](/entities/user)'s subscription request. Should be signed by the instance hosting the group.
<Row>
<Col>
<Properties name="GroupSubscribeReject">
<Property name="type" type="string" required={true}>
Must be `pub.versia:groups/SubscribeReject`.
</Property>
<Property name="uri" type="null" required={false}>
This is a [**Transient Entity**](/entities#transient-entities) and does not have a URI.
</Property>
<Property name="subscriber" type="URI" required={true} typeLink="/types#uri">
URI of the [User](/entities/user) subscribing to the group.
</Property>
<Property name="group" type="URI" required={true} typeLink="/types#uri">
URI of the group that rejected the subscription.
</Property>
</Properties>
</Col>
<Col sticky>
```jsonc {{ title: "Example GroupSubscribeReject" }}
{
"type": "pub.versia:groups/SubscribeReject",
"id": "9a7e9345-4e4a-4d5a-8301-4dbbfe777ca0",
"subscriber": "https://bob.social/users/e9277471-8aa1-4d40-a3d0-0878e818ccdc",
"group": "https://example.com/groups/ed480922-b095-4f09-9da5-c995be8f5960",
"created_at": "2021-01-01T00:00:00Z"
}
```
</Col>
</Row>
## Federation
Group federation represents a particularly challenging problem, as it requires a way to make sure every single [Note](/entities/note) posted to it is delivered to every single member of the group.
All [Notes](/entities/note) posted to a group (using the `group` field) must be sent to its instance's [shared inbox](/federation#inboxes). Groups do not have an inbox of their own.
Once this is done, the group's instance must then federate this [Note](/entities/note) to every member of the group. However, this cannot be done the "normal way", as the group's instance does not have the private key to [sign](/signatures) the [Note](/entities/note).
### GroupFederate
The `GroupFederate` entity allows a group to federate a note to all of its members, without needing to sign the note itself. It contains a URI to the note being federated, which must be fetched by the receiving instances. This entity is signed by the group's instance.
<Row>
<Col>
<Properties name="GroupFederate">
<Property name="type" type="string" required={true}>
Must be `pub.versia:groups/Federate`.
</Property>
<Property name="uri" type="null" required={false}>
This is a [**Transient Entity**](/entities#transient-entities) and does not have a URI.
</Property>
<Property name="note" type="URI" required={true} typeLink="/types#uri">
URI of the note to federate.
</Property>
<Property name="group" type="URI" required={true} typeLink="/types#uri">
URI of the group federating the note.
</Property>
</Properties>
</Col>
<Col sticky>
```jsonc {{ title: "Example GroupFederate" }}
{
"type": "pub.versia:groups/Federate",
"id": "9a7e9345-4e4a-4d5a-8301-4dbbfe777ca0",
"note": "https://example.com/notes/ed480922-b095-4f09-9da5-c995be8f5960",
"group": "https://example.com/groups/ed480922-b095-4f09-9da5-c995be8f5960",
"created_at": "2021-01-01T00:00:00Z"
}
```
</Col>
</Row>

View file

@ -29,7 +29,7 @@ This extension adds the following metadata to instances:
<Row> <Row>
<Col> <Col>
<Properties> <Properties name="InstanceMessagingExtension">
<Property name="endpoint" type="string" required={true}> <Property name="endpoint" type="string" required={true}>
The endpoint to send federation debug messages to. The endpoint to send federation debug messages to.
@ -64,7 +64,7 @@ This extension adds the following metadata to instances:
}, },
"compatibility": { "compatibility": {
"versions": [ "versions": [
"0.4.0" "0.5.0"
], ],
"extensions": [ "extensions": [
"pub.versia:reactions", "pub.versia:reactions",

View file

@ -0,0 +1,113 @@
export const metadata = {
title: "Interaction Controls Extension",
description: "Allows users to control who can interact with their Notes"
}
# Interaction Controls
Often, it is desirable to post a Note, but control who is allowed to interact with it (e.g. send replies, like, dislike, etc.). This has traditionally not been possible in most federated networks: the Interaction Controls extension adds this possibility.
## Usage
The entity defined in this document must be inserted in the `pub.versia:interaction_controls` key of a [Note](/entities/note)'s extensions field.
```jsonc {{ title: "Example Usage" }}
{
"id": "456df8ed-daf1-4062-abab-491071c7b8dd",
"type": "Note",
"uri": "https://versia.social/notes/456df8ed-daf1-4062-abab-491071c7b8dd",
"created_at": "2024-04-09T01:38:51.743Z",
"collections": {
"replies": "https://versia.social/notes/456df8ed-daf1-4062-abab-491071c7b8dd/replies",
"quotes": "https://versia.social/notes/456df8ed-daf1-4062-abab-491071c7b8dd/quotes"
},
"content": {
"text/plain": {
"content": "Hello, world :happy_face:!"
}
},
"extensions": { // [!code focus:9]
"pub.versia:interaction_controls": {
"reply": {
"allowed": ["followers"],
},
}
}
}
```
## Entity Definition
<Row>
<Col>
<Properties name="InteractionControls">
<Property name="interaction_type">
Describes permissions for a specific interaction.
```typescript
type InteractionGroup = |
"everyone" |
"followers" |
"followed" |
"group" |
"mutuals";
type InteractionPermissions = {
allowed?: InteractionGroup[];
disallowed?: InteractionGroup[];
}
```
Permissions can either be whitelist (`allowed` property) or blacklist (`disallowed` property). Both options are mutually exclusive.
In order of priority:
- `everyone`: Includes every single User in the federation.
- `mentioned`: Includes every mentioned User.
- `followers`: Includes every follower of the author.
- `following`: Includes every User that the author follows.
- `mutuals`: Includes every mutual of the author (that is, every User that is both a follower and followed by the author).
- `group`: Includes every User in the [Group](/extensions/groups) that this Note was posted to, if any. If Note is not posted to a [Group](/extensions/groups), this value has no effect.
Permission groups are evaluated from highest to lowest priority: if two groups conflict each other, the group with the highest priority must be used.
</Property>
</Properties>
</Col>
<Col sticky>
```jsonc {{ title: "Example"}}
{
"reply": {
"allowed": [
"group"
],
},
"pub.versia:likes#Like": {
"disallowed": [
"everyone"
]
}
}
```
</Col>
</Row>
## Usage
### Interaction Types
The following interaction types are defined as part of the core Versia spec:
- `reply`: Sending a Note with `replies_to` including this Note.
- `quote`: Sending a Note with `quotes` including this Note.
Extensions **may** choose to register their own interaction types (such as `pub.versia:likes#Like` for the [Like Extension](/extensions/likes)). The naming scheme for interaction types is identical to [Extensions](/extensions)'s `type` property, but with a hashtag (`#`) in place of a forward slash (`/`).
### Handling Permission Errors
Implementations that find a user attempting to create an interaction they are not allowed to **MUST** return a `403 Forbidden` HTTP status code when processing the Note during federation. The Note **must** also be discarded.
It is important for implementations to backfill any related [Collections](/structures/collection)/[URI Collections](/structures/collection#uri-collection) (e.g. user followers) in order to not incorrectly reject Notes based off of outdated data.
<Note>
To avoid server load from constant Collection refreshing, implementations **could** only refetch associated Collections when forbidden interactions are detected, then recalculate permissions again.
</Note>

View file

@ -18,7 +18,7 @@ Likes are a way for users to show appreciation for a note, like Twitter's "heart
<Row> <Row>
<Col> <Col>
<Properties> <Properties name="Like">
<Property name="type" type="string" required={true}> <Property name="type" type="string" required={true}>
Must be `pub.versia:likes/Like`. Must be `pub.versia:likes/Like`.
</Property> </Property>
@ -55,7 +55,7 @@ Dislikes are a way for users to show disapproval for a note, like YouTube's "dis
<Row> <Row>
<Col> <Col>
<Properties> <Properties name="Dislike">
<Property name="type" type="string" required={true}> <Property name="type" type="string" required={true}>
Must be `pub.versia:likes/Dislike`. Must be `pub.versia:likes/Dislike`.
</Property> </Property>
@ -92,8 +92,8 @@ To undo a like or dislike, a [Delete](/entities/delete) entity should be used. T
The Likes extension adds the following collections to the [User](/entities/user) entity: The Likes extension adds the following collections to the [User](/entities/user) entity:
- `likes`: A [Collection](/structures/collection) of all the notes the user has liked. - `likes`: A [URI Collection](/structures/collection#uri-collection) of all the notes the user has liked.
- `dislikes`: A [Collection](/structures/collection) of all the notes the user has disliked. - `dislikes`: A [URI Collection](/structures/collection#uri-collection) of all the notes the user has disliked.
```jsonc ```jsonc
{ {
@ -104,4 +104,34 @@ The Likes extension adds the following collections to the [User](/entities/user)
"pub.versia:likes/Likes": "https://example.com/users/6e0204a2-746c-4972-8602-c4f37fc63bbe/likes", "pub.versia:likes/Likes": "https://example.com/users/6e0204a2-746c-4972-8602-c4f37fc63bbe/likes",
"pub.versia:likes/Dislikes": "https://example.com/users/6e0204a2-746c-4972-8602-c4f37fc63bbe/dislikes" "pub.versia:likes/Dislikes": "https://example.com/users/6e0204a2-746c-4972-8602-c4f37fc63bbe/dislikes"
} }
} }
```
## Note Collections
The Likes extension adds the following collections to the [Note](/entities/note) entity:
- `likes`: A [URI Collection](/structures/collection#uri-collection) of all the likes the note has received.
- `dislikes`: A [URI Collection](/structures/collection#uri-collection) of all the dislikes the note has received.
```jsonc
{
"type": "Note",
...
"collections": {
...
"pub.versia:likes/Likes": "https://example.com/notes/fmKZ763jzIU8/likes",
"pub.versia:likes/Dislikes": "https://example.com/notes/fmKZ763jzIU8/dislikes"
}
}
```
## Interaction Types
<Note>
This section only applies to implementors of the [Interaction Controls Extension](/extensions/interaction-controls).
</Note>
This extension registers the following interaction types:
- `pub.versia:likes#Like`, for liking a Note
- `pub.versia:likes#Dislike`, for disliking a Note

View file

@ -32,9 +32,9 @@ Migration happens in three steps:
<Row> <Row>
<Col> <Col>
<Properties> <Properties name="Migration">
<Property name="uri" type="null" required={false}> <Property name="uri" type="null" required={false}>
This entity does not have a URI. This is a [**Transient Entity**](/entities#transient-entities) and does not have a URI.
</Property> </Property>
<Property name="type" type="string" required={true}> <Property name="type" type="string" required={true}>
Must be `pub.versia:migration/Migration`. Must be `pub.versia:migration/Migration`.
@ -69,7 +69,7 @@ The following extensions to [User](/entities/user) are used by the migration ext
<Row> <Row>
<Col> <Col>
<Properties> <Properties name="MigrationExtension">
<Property name="previous" type="URI" required={true} typeLink="/types#uri"> <Property name="previous" type="URI" required={true} typeLink="/types#uri">
If this user has migrated from another instance, this property **MUST** be set to the URI of the user on the previous instance. If this user has migrated from another instance, this property **MUST** be set to the URI of the user on the previous instance.
</Property> </Property>

View file

@ -44,7 +44,7 @@ Extensions can be found in two places: an [Entity](/entities#entity-definition)'
<Row> <Row>
<Col> <Col>
<Properties> <Properties name="EntityExtension">
<Property name="extensions" type="Record<string, JSONData>" required={false}> <Property name="extensions" type="Record<string, JSONData>" required={false}>
Custom extensions to the entity. Custom extensions to the entity.
@ -59,7 +59,7 @@ Extensions can be found in two places: an [Entity](/entities#entity-definition)'
```jsonc {{ title: "Example Entity Extension" }} ```jsonc {{ title: "Example Entity Extension" }}
{ {
"type": "Group", "type": "pub.versia:groups/Group",
"id": "ed480922-b095-4f09-9da5-c995be8f5960", "id": "ed480922-b095-4f09-9da5-c995be8f5960",
"uri": "https://example.com/groups/ed480922-b095-4f09-9da5-c995be8f5960", "uri": "https://example.com/groups/ed480922-b095-4f09-9da5-c995be8f5960",
"name": null, "name": null,
@ -85,7 +85,7 @@ Extensions can be found in two places: an [Entity](/entities#entity-definition)'
<Row> <Row>
<Col> <Col>
<Properties> <Properties name="CustomEntity">
<Property name="type" type="string" required={true}> <Property name="type" type="string" required={true}>
The extension type. [Must follow naming conventions](#naming). The extension type. [Must follow naming conventions](#naming).
</Property> </Property>

View file

@ -19,7 +19,7 @@ Note that there is no `question` field: the question should be included in the `
<Row> <Row>
<Col> <Col>
<Properties> <Properties name="Polls">
<Property name="options" type="ContentFormat[]" required="true"> <Property name="options" type="ContentFormat[]" required="true">
Array of options for the poll. Each option is a [ContentFormat](/structures/content-format) that can contain the same properties as a Note's `content` (e.g. [Custom Emojis](/extensions/custom-emojis) or HTML hyperlinks). Array of options for the poll. Each option is a [ContentFormat](/structures/content-format) that can contain the same properties as a Note's `content` (e.g. [Custom Emojis](/extensions/custom-emojis) or HTML hyperlinks).
</Property> </Property>
@ -29,8 +29,8 @@ Note that there is no `question` field: the question should be included in the `
<Property name="multiple_choice" type="boolean" required="true"> <Property name="multiple_choice" type="boolean" required="true">
Whether the poll allows multiple votes to be cast for different options. Whether the poll allows multiple votes to be cast for different options.
</Property> </Property>
<Property name="expires_at" type="ISO 8601" typeLink="/types#iso8601"> <Property name="expires_at" type="RFC3339" typeLink="/types#rfc3339">
ISO 8601 timestamp of when the poll ends and no more votes can be cast. If not present, the poll does not expire. RFC 3339 timestamp of when the poll ends and no more votes can be cast. If not present, the poll does not expire.
</Property> </Property>
</Properties> </Properties>
</Col> </Col>
@ -45,6 +45,10 @@ Note that there is no `question` field: the question should be included in the `
"created_at": "2024-06-19T01:07:44.139Z", "created_at": "2024-06-19T01:07:44.139Z",
"author": "https://versia.social/users/018eb863-753f-76ff-83d6-fd590de7740a", "author": "https://versia.social/users/018eb863-753f-76ff-83d6-fd590de7740a",
"category": "microblog", "category": "microblog",
"collections": {
"replies": "https://versia.social/notes/01902e09-0f8b-72de-8ee3-9afc0cf5eae1/replies",
"quotes": "https://versia.social/notes/01902e09-0f8b-72de-8ee3-9afc0cf5eae1/quotes"
},
"content": { "content": {
"text/plain": { "text/plain": {
"content": "What is your favourite color?" "content": "What is your favourite color?"
@ -94,7 +98,7 @@ If a vote is cast to a poll that is closed, the vote should be rejected with a `
<Row> <Row>
<Col> <Col>
<Properties> <Properties name="Vote">
<Property name="type" type="string" required="true"> <Property name="type" type="string" required="true">
Must be `pub.versia:polls/Vote`. Must be `pub.versia:polls/Vote`.
</Property> </Property>
@ -125,4 +129,13 @@ If a vote is cast to a poll that is closed, the vote should be rejected with a `
``` ```
</Col> </Col>
</Row> </Row>
## Interaction Types
<Note>
This section only applies to implementors of the [Interaction Controls Extension](/extensions/interaction-controls).
</Note>
This extension registers the following interaction types:
- `pub.versia:polls#Vote`, for voting on a Poll attached to a Note.

View file

@ -15,7 +15,7 @@ User reactions are (like every other entity) federated to all followers, and can
<Row> <Row>
<Col> <Col>
<Properties> <Properties name="Reaction">
<Property name="type" type="string" required> <Property name="type" type="string" required>
Must be `pub.versia:reactions/Reaction`. Must be `pub.versia:reactions/Reaction`.
</Property> </Property>
@ -50,45 +50,28 @@ User reactions are (like every other entity) federated to all followers, and can
</Col> </Col>
</Row> </Row>
## Extensions to Note ## Note Collections
The Reactions Extension extends the [Note](/entities/note) entity with the following fields: The Likes extension adds the following collections to the [Note](/entities/note) entity:
<Row> - `reactions`: A [URI Collection](/structures/collection#uri-collection) of all the reactions to the note.
<Col>
<Properties>
<Property name="reactions" type="array" required>
URI to a [Collection](/structures/collection) of the [Reactions](#entity-definition) attached to the note.
</Property>
</Properties>
</Col>
<Col sticky> ```jsonc
{
```jsonc {{ title: "Example Note" }} "type": "Note",
{ ...
"id": "01902e09-0f8b-72de-8ee3-9afc0cf5eae1", "collections": {
"type": "Note", // [!code focus] ...
"uri": "https://versia.social/notes/01902e09-0f8b-72de-8ee3-9afc0cf5eae1", "pub.versia:reactions/Reactions": "https://example.com/publications/f08a124e-fe90-439e-8be4-15a428a72a19/reactions"
"created_at": "2024-06-19T01:07:44.139Z",
"author": "https://versia.social/users/018eb863-753f-76ff-83d6-fd590de7740a",
"category": "microblog",
"content": {
"text/plain": {
"content": "Bababooey."
}
},
"extensions": { // [!code focus:5]
"pub.versia:reactions": {
"reactions": "https://versia.social/notes/01902e09-0f8b-72de-8ee3-9afc0cf5eae1/reactions"
}
},
"group": "public",
"is_sensitive": false,
"mentions": [],
} }
}
```
``` ## Interaction Types
</Col> <Note>
</Row> This section only applies to implementors of the [Interaction Controls Extension](/extensions/interaction-controls).
</Note>
This extension registers the following interaction types:
- `pub.versia:reactions#React`, for adding a Reaction to a Note.

View file

@ -13,7 +13,10 @@ When an instance receives a report, it *should* be reviewed by a moderator or ad
<Row> <Row>
<Col> <Col>
<Properties> <Properties name="Report">
<Property name="uri" type="null" required={false}>
This is a [**Transient Entity**](/entities#transient-entities) and does not have a URI.
</Property>
<Property name="type" type="string" required={true}> <Property name="type" type="string" required={true}>
Must be `pub.versia:reports/Report`. Must be `pub.versia:reports/Report`.
</Property> </Property>
@ -23,8 +26,8 @@ When an instance receives a report, it *should* be reviewed by a moderator or ad
<Property name="reported" type="URI[]" required={true} typeLink="/types#uri"> <Property name="reported" type="URI[]" required={true} typeLink="/types#uri">
URIs of the content being reported. URIs of the content being reported.
</Property> </Property>
<Property name="reason" type="string" required={true}> <Property name="tags" type="string[]" required={true}>
Reason for the report. Should be concise and clear, such as `spam`, `harassment`, `misinformation`, etc. Report tags. Should be concise and clear, such as `spam`, `harassment`, `misinformation`, etc. Used for categorization.
</Property> </Property>
<Property name="comment" type="string" required={false}> <Property name="comment" type="string" required={false}>
Additional comments about the report. Can be used to provide more context or details. Additional comments about the report. Can be used to provide more context or details.
@ -39,12 +42,14 @@ When an instance receives a report, it *should* be reviewed by a moderator or ad
"id": "6f3001a1-641b-4763-a9c4-a089852eec84", "id": "6f3001a1-641b-4763-a9c4-a089852eec84",
"type": "pub.versia:reports/Report", "type": "pub.versia:reports/Report",
"author": "https://example.com/users/6f3001a1-641b-4763-a9c4-a089852eec84", "author": "https://example.com/users/6f3001a1-641b-4763-a9c4-a089852eec84",
"uri": "https://example.com/reports/f7bbf7fc-88d2-47dd-b241-5d1f770a10f0",
"reported": [ "reported": [
"https://test.com/publications/46f936a3-9a1e-4b02-8cde-0902a89769fa", "https://test.com/publications/46f936a3-9a1e-4b02-8cde-0902a89769fa",
"https://test.com/publications/213d7c56-fb9b-4646-a4d2-7d70aa7d106a" "https://test.com/publications/213d7c56-fb9b-4646-a4d2-7d70aa7d106a"
], ],
"reason": "spam", "tags": [
"spam",
"harassment"
],
"comment": "This is spam." "comment": "This is spam."
} }
``` ```

View file

@ -17,7 +17,7 @@ When a user shares a note, the note's original author **must** receive the entit
<Row> <Row>
<Col> <Col>
<Properties> <Properties name="Share">
<Property name="type" type="string" required={true}> <Property name="type" type="string" required={true}>
Must be `pub.versia:share/Share`. Must be `pub.versia:share/Share`.
</Property> </Property>
@ -44,4 +44,30 @@ When a user shares a note, the note's original author **must** receive the entit
``` ```
</Col> </Col>
</Row> </Row>
## Note Collections
The Share extension adds the following collections to the [Note](/entities/note) entity:
- `shares`: A [URI Collection](/structures/collection#uri-collection) of all the shares of the note.
```jsonc
{
"type": "Note",
...
"collections": {
...
"pub.versia:share/Shares": "https://example.com/notes/fmKZ763jzIU8/shares"
}
}
```
## Interaction Types
<Note>
This section only applies to implementors of the [Interaction Controls Extension](/extensions/interaction-controls).
</Note>
This extension registers the following interaction types:
- `pub.versia:share#Share`, for sharing a Note.

View file

@ -14,7 +14,7 @@ All properties are optional.
<Row> <Row>
<Col> <Col>
<Properties> <Properties name="Vanity">
<Property name="avatar_overlays" type="ContentFormat[]" typeLink="/structures/content-format" required={false}> <Property name="avatar_overlays" type="ContentFormat[]" typeLink="/structures/content-format" required={false}>
Overlay images to be placed on top of the user's avatar, like this: [example overlay from Discord](https://cdn.discordapp.com/avatar-decoration-presets/a_949a575b693c81ced8f56a7579d0969f.png). Overlay images to be placed on top of the user's avatar, like this: [example overlay from Discord](https://cdn.discordapp.com/avatar-decoration-presets/a_949a575b693c81ced8f56a7579d0969f.png).
@ -61,7 +61,7 @@ All properties are optional.
type LanguageCode = string; type LanguageCode = string;
``` ```
</Property> </Property>
<Property name="birthday" type="ISO8601" required={false} typeLink="/types#iso8601"> <Property name="birthday" type="RFC3339" required={false} typeLink="/types#rfc3339">
User's birthday. If year is left out or set to `0000`, implementations **SHOULD** not display the year. User's birthday. If year is left out or set to `0000`, implementations **SHOULD** not display the year.
</Property> </Property>
<Property name="location" type="string" required={false}> <Property name="location" type="string" required={false}>
@ -69,6 +69,9 @@ All properties are optional.
Location does not need to be precise, and can be as simple as `+46+002/` (France) or `+48.52+002.20/` (Paris, France). Location does not need to be precise, and can be as simple as `+46+002/` (France) or `+48.52+002.20/` (Paris, France).
</Property> </Property>
<Property name="timezone" type="string" required={false}>
User's timezone. Should be a valid [IANA timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) string.
</Property>
<Property name="aliases" type="URI[]" required={false} typeLink="/types#uri"> <Property name="aliases" type="URI[]" required={false} typeLink="/types#uri">
Versia profiles that should be considered aliases of this profile. Versia profiles that should be considered aliases of this profile.
</Property> </Property>
@ -124,6 +127,7 @@ All properties are optional.
}, },
"birthday": "1998-04-12", "birthday": "1998-04-12",
"location": "+40.6894-074.0447/", "location": "+40.6894-074.0447/",
"timezone": "America/New_York",
"aliases": [ "aliases": [
"https://burger.social/accounts/349ee237-c672-41c1-aadc-677e185f795a", "https://burger.social/accounts/349ee237-c672-41c1-aadc-677e185f795a",
"https://versia.social/users/f565ef02-035d-4974-ba5e-f62a8558331d" "https://versia.social/users/f565ef02-035d-4974-ba5e-f62a8558331d"

View file

@ -22,15 +22,15 @@ Messages sent over the WebSocket connection are JSON objects.
<Row> <Row>
<Col> <Col>
<Properties> <Properties name="WebSocketMessage">
<Property name="signature" type="string" required={true}> <Property name="signature" type="string" required={true}>
Same as the `X-Signature` header in HTTP requests. Same as the `Versia-Signature` header in HTTP requests.
</Property> </Property>
<Property name="nonce" type="string" required={true}> <Property name="signed_at" type="string" required={true}>
Same as the `X-Nonce` header in HTTP requests. Same as the `Versia-Signed-At` header in HTTP requests.
</Property> </Property>
<Property name="signed_by" type="URI" required={true}> <Property name="signed_by" type="URI" required={true}>
Same as the `X-Signed-By` header in HTTP requests. Same as the `Versia-Signed-By` header in HTTP requests.
</Property> </Property>
<Property name="entity" type="Entity" required={true} typeLink="/entities"> <Property name="entity" type="Entity" required={true} typeLink="/entities">
Same as the request body in HTTP requests. Must be a string (stringified JSON), not JSON. Same as the request body in HTTP requests. Must be a string (stringified JSON), not JSON.
@ -42,8 +42,8 @@ Messages sent over the WebSocket connection are JSON objects.
```jsonc {{ 'title': 'Example Message' }} ```jsonc {{ 'title': 'Example Message' }}
{ {
"signature": "post /users/1/inbox a2ebc29eb6762a9164fbcffc9271e8a53562a5e725e7187ea7d88d03cbe59341 n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=", "signature": "/CjB2L9bcvRg+uP19B4/rqy7Ji9/cqMFPlL3GVCIndnQjYyOpBzJEAl9weDnXm7Jrqa3y6sBC+EYWKThO2r9Bw==",
"nonce": "a2ebc29eb6762a9164fbcffc9271e8a53562a5e725e7187ea7d88d03cbe59341", "signed_at": "1729241807",
"signed_by": "https://bongo.social/users/63a00ab3-39b1-49eb-b88e-ed65d2361f3e", "signed_by": "https://bongo.social/users/63a00ab3-39b1-49eb-b88e-ed65d2361f3e",
"entity": "{\"id\":\"9a8928b6-2526-4979-aab1-ef2f88cd5700\",\"type\":\"Delete\",\"created_at\":\"2022-01-01T12:00:00Z\",\"author\":\"https://bongo.social/users/63a00ab3-39b1-49eb-b88e-ed65d2361f3e\",\"deleted\":\"https://bongo.social/notes/54059ce2-9332-46fa-bf6a-598b5493b81b\"}" "entity": "{\"id\":\"9a8928b6-2526-4979-aab1-ef2f88cd5700\",\"type\":\"Delete\",\"created_at\":\"2022-01-01T12:00:00Z\",\"author\":\"https://bongo.social/users/63a00ab3-39b1-49eb-b88e-ed65d2361f3e\",\"deleted\":\"https://bongo.social/notes/54059ce2-9332-46fa-bf6a-598b5493b81b\"}"
} }

View file

@ -72,7 +72,7 @@ Accept: application/json
}, },
"compatibility": { "compatibility": {
"versions": [ "versions": [
"0.4.0" "0.5.0"
], ],
"extensions": [ "extensions": [
"pub.versia:reactions", "pub.versia:reactions",

View file

@ -0,0 +1,174 @@
export const metadata = {
title: 'Federation Example',
description:
'A description of how a typical federation flow might look like',
}
# Example
This page describes a typical federation flow between two servers, `a.social` and `b.social`, in several different contexts. {{ className: 'lead' }}
<Note>
All examples, domains, names and timestamps are **fictional** and are used **for illustrative purposes only**.
Some details have been slightly simplified for clarity.
</Note>
## Sending a Note
`@alice` on `a.social` creates a note with the following content:
```markdown
Hello, @joe@b.social! How are you doing today?
```
`@alice` has mentioned `@joe@b.social` in the note.
### Resolving the Mention
`a.social` resolves the mention by querying `b.social` for the user `joe` using WebFinger.
```bash {{ title: "cURL example" }}
curl https://b.social/.well-known/webfinger?resource=acct:joe@b.social -H "Accept: application/json"
```
`b.social` responds with the following JSON:
```json
{
"subject": "acct:joe@b.social",
"links": [
{ // [!code focus:5]
"rel": "self",
"type": "application/json",
"href": "https://b.social/users/joe"
}
]
}
```
<Note>
In a real Versia implementation, usernames would **not** be included in user profile's URL, as they can be changed. Instead, the `id` could be used.
This is done for simplicity in this example.
</Note>
### Fetching the User
`a.social` fetches the user profile of `joe` from `b.social` using the URL provided in the WebFinger response.
```bash
curl https://b.social/users/joe \
-H "Accept: application/json" \
-H "User-Agent: CoolServer/1.0 (https://coolserver.com)" \
# The request is signed by a.social's instance private key
-H "Versia-Signature: /CjB2L9bcvRg+uP19B4/rqy7Ji9/cqMFPlL3GVCIndnQjYyOpBzJEAl9weDnXm7Jrqa3y6sBC+EYWKThO2r9Bw==" \
-H "Versia-Signed-By: https://a.social/users/alice" \
-H "Versia-Signed-At: 1729241687"
```
`b.social` responds with the following JSON:
```json
{
"id": "bde22zi3ca8762", // [!code focus:10]
"type": "User",
"uri": "https://b.social/users/joe",
"created_at": "2024-10-13T18:48:19Z",
"avatar": {
"image/webp": {
"content": "https://cdn.b.social/avatars/joe.webp",
"remote": true
}
},
"collections": {
"featured": "https://b.social/users/joe/featured",
"followers": "https://b.social/users/joe/followers",
"following": "https://b.social/users/joe/following",
"outbox": "https://b.social/users/joe/outbox"
}, // [!code focus:9]
"display_name": "Joe Swanson (Winter Arc :gigachad:)",
"inbox": "https://b.social/inbox",
"public_key": {
"actor": "https://b.social/users/joe",
"algorithm": "ed25519",
"key": "MCowBQYDK2VwAyEAOSCcfsde0Ya3vf/P6lzgK0pA8qCISqneaze3omLlQCQ="
},
"username": "joe",
"extensions": {
"pub.versia:custom_emojis": {
"emojis": [
{
"name": ":gigachad:",
"content": {
"image/png": {
"content": "https://cdn.b.social/emojis/gigachad.png",
"remote": true
}
}
}
]
}
},
}
```
`a.social` now has the user profile of `joe` and can display the note with the correct user information.
### Serializing the Note
Finally, `a.social` serializes the note to send it to `joe`.
```json
{
"id": "782addd9-c051-4eea-8ba4-23d561d0c5bb", // [!code focus:6]
"type": "Note",
"uri": "https://a.social/notes/782addd9-c051-4eea-8ba4-23d561d0c5bb",
"created_at": "2024-12-01T12:19:06Z",
"author": "https://a.social/users/alice",
"category": "microblog",
"collections": {
"replies": "https://a.social/notes/782addd9-c051-4eea-8ba4-23d561d0c5bb/replies",
"quotes": "https://a.social/notes/782addd9-c051-4eea-8ba4-23d561d0c5bb/quotes"
}, // [!code focus:11]
"content": {
"text/html": {
"content": "Hello, <a class=\"u-url mention\" href=\"https://b.social/users/joe\">@joe@b.social</a>! How are you doing today?",
"remote": false,
},
"text/plain": {
"content": "Hello, @joe@b.social! How are you doing today?",
"remote": false,
}
},
"group": "public",
"mentions": [ // [!code focus:3]
"https://b.social/users/joe"
]
}
```
It is now time for `a.social` to send the note to `joe`.
### Sending the Note
`a.social` sends the note to `joe`'s inbox at `b.social`.
```bash
curl -X POST https://b.social/inbox \
-H "Content-Type: application/json; charset=utf-8" \
-H "Accept: application/json" \
-H "User-Agent: CoolerServer/1.0 (https://coolerserver.com)" \
# The request is signed by Alice's private key
-H "Versia-Signature: 9BrfplAPVH6OEqlV5eX7MazaZAInSCPODZcBEvMliBi/OwfbCAsezlb0O9jUX9ZcbBA68ThA4WUgS9V+42rfAQ==" \
-H "Versia-Signed-By: https://a.social/users/alice" \
-H "Versia-Signed-At: 1733051946"
```
`b.social` responds with a `202 Accepted` status code.
### Displaying the Note
The software on `b.social` processes the note and shows it to `joe` using whatever interface it has.
`joe` can now see the note from `@alice` on `a.social` and respond to it.

View file

@ -14,20 +14,20 @@ ALL kinds of HTTP requests/responses between instances **MUST** include a [Signa
<Row> <Row>
<Col> <Col>
<Properties> <Properties name="HTTP Request">
<Property name="Accept" type="string" required={true}> <Property name="Accept" type="string" required={true}>
Must include `application/json`. Must include `application/json`.
</Property> </Property>
<Property name="Content-Type" type="string" required={true}> <Property name="Content-Type" type="string" required={true}>
Must include `application/json; charset=utf-8`, if the request has a body. Must include `application/json; charset=utf-8`, if the request has a body.
</Property> </Property>
<Property name="X-Signature" type="string" required={false}> <Property name="Versia-Signature" type="string" required={false}>
See [Signatures](/signatures) for more information. See [Signatures](/signatures) for more information.
</Property> </Property>
<Property name="X-Signed-By" type="URI" required={false} typeLink="/types#uri"> <Property name="Versia-Signed-By" type="URI" required={false} typeLink="/types#uri">
See [Signatures](/signatures). See [Signatures](/signatures).
</Property> </Property>
<Property name="X-Nonce" type="string" required={false}> <Property name="Versia-Signed-At" type="string" required={false}>
See [Signatures](/signatures). See [Signatures](/signatures).
</Property> </Property>
<Property name="User-Agent" type="string" required={false}> <Property name="User-Agent" type="string" required={false}>
@ -40,28 +40,38 @@ ALL kinds of HTTP requests/responses between instances **MUST** include a [Signa
POST https://bob.com/users/1/inbox HTTP/1.1 POST https://bob.com/users/1/inbox HTTP/1.1
Accept: application/json Accept: application/json
User-Agent: CoolServer/1.0 (https://coolserver.com) User-Agent: CoolServer/1.0 (https://coolserver.com)
X-Signature: post /users/1/inbox a2ebc29eb6762a9164fbcffc9271e8a53562a5e725e7187ea7d88d03cbe59341 n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg= Versia-Signature: /CjB2L9bcvRg+uP19B4/rqy7Ji9/cqMFPlL3GVCIndnQjYyOpBzJEAl9weDnXm7Jrqa3y6sBC+EYWKThO2r9Bw==
X-Signed-By: https://example.com/users/1 Versia-Signed-By: https://example.com/users/1
X-Nonce: a2ebc29eb6762a9164fbcffc9271e8a53562a5e725e7187ea7d88d03cbe59341 Versia-Signed-At: 1729241687
``` ```
</Col> </Col>
</Row> </Row>
## Rate limits
Implementations **MUST** respect the rate limits of remote instances.
IETF draft [draft-polli-ratelimit-headers-02](https://www.ietf.org/archive/id/draft-polli-ratelimit-headers-02.html) **MUST** be used to communicate rate limits. Other rate limit headers/formats are not allowed.
<Note>
This IETF draft is, well, a draft. However, there are no standards for rate limiting in HTTP, so this is the best we have.
</Note>
## Responses ## Responses
<Row> <Row>
<Col> <Col>
<Properties> <Properties name="HTTP Response">
<Property name="Content-Type" type="string" required={true}> <Property name="Content-Type" type="string" required={true}>
Must include `application/json; charset=utf-8`. Must include `application/json; charset=utf-8`.
</Property> </Property>
<Property name="X-Signature" type="string" required={false}> <Property name="Versia-Signature" type="string" required={false}>
See [Signatures](/signatures) for more information. See [Signatures](/signatures) for more information.
</Property> </Property>
<Property name="X-Signed-By" type="URI" required={false} typeLink="/types#uri"> <Property name="Versia-Signed-By" type="URI" required={false} typeLink="/types#uri">
See [Signatures](/signatures). See [Signatures](/signatures).
</Property> </Property>
<Property name="X-Nonce" type="string" required={false}> <Property name="Versia-Signed-At" type="string" required={false}>
See [Signatures](/signatures). See [Signatures](/signatures).
</Property> </Property>
</Properties> </Properties>
@ -70,9 +80,9 @@ ALL kinds of HTTP requests/responses between instances **MUST** include a [Signa
```http {{ 'title': 'Example Response' }} ```http {{ 'title': 'Example Response' }}
HTTP/1.1 200 OK HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8 Content-Type: application/json; charset=utf-8
X-Signature: get /users/1/followers 8f872d4609d26819d03a7d60ce3db68f5b0dd5a80d5930260294f237e670ab76 YDA64iuZiGG847KPM+7BvnWKITyGyTwHbb6fVYwRx1I Versia-Signature: /CjB2L9bcvRg+uP19B4/rqy7Ji9/cqMFPlL3GVCIndnQjYyOpBzJEAl9weDnXm7Jrqa3y6sBC+EYWKThO2r9Bw==+7BvnWKITyGyTwHbb6fVYwRx1I
X-Signed-By: https://example.com/users/1 Versia-Signed-By: https://example.com/users/1
X-Nonce: 8f872d4609d26819d03a7d60ce3db68f5b0dd5a80d5930260294f237e670ab76 Versia-Signed-At: 1729241717
``` ```
</Col> </Col>
</Row> </Row>

View file

@ -39,6 +39,6 @@ Shared inboxes are defined in the [Instance Metadata](/entities/instance-metadat
## Outboxes ## Outboxes
In addition to inboxes, every user has an outbox (e.g., `/users/3/outbox`). The outbox is simply a collection of all the messages that a user has sent. When a user sends a message to another user, a copy of that message is accessible in the sender's outbox. In addition to inboxes, every user has an outbox (e.g., `/users/3/outbox`). The outbox is simply a [Collection](/structures/collection) of all the messages that a user has sent. When a user sends a message to another user, a copy of that message is accessible in the sender's outbox.
Outboxes are very useful for "backfilling" data when a new instance joins the network. By resolving the outboxes of all new users it encounters, a new instance can quickly catch up on all old messages. Outboxes are very useful for "backfilling" data when a new instance joins the network. By resolving the outboxes of all new users it encounters, a new instance can quickly catch up on all old messages.

View file

@ -17,7 +17,7 @@ Implementations **MUST** strictly validate all incoming data to ensure that it i
Things that should be validated include, but are not limited to: Things that should be validated include, but are not limited to:
- The presence of **all required fields**. - The presence of **all required fields**.
- The **format** of all fields (integers should not be strings, dates should be in ISO 8601 format, etc.). - The **format** of all fields (integers should not be strings, timestamps should be in RFC 3339 format, etc.).
- The presence of **all required headers**. - The presence of **all required headers**.
- The presence of a **valid signature**. - The presence of a **valid signature**.
- The **length** of all fields (for example, the `username` field on a `User` entity) should be at least 1 character long. - The **length** of all fields (for example, the `username` field on a `User` entity) should be at least 1 character long.

View file

@ -35,6 +35,7 @@ The words **MUST**, **MUST NOT**, **SHOULD**, **SHOULD NOT**, and **MAY** are us
The Versia Protocol uses the following terms: The Versia Protocol uses the following terms:
- **Entity**: A generic term for any JSON object in the protocol, such as a [User](./entities/user), a [Note](./entities/note), or a [Like](./extensions/likes). Entities are uniquely identified by their `id` property. - **Entity**: A generic term for any JSON object in the protocol, such as a [User](./entities/user), a [Note](./entities/note), or a [Like](./extensions/likes). Entities are uniquely identified by their `id` property.
- [**Transient Entity**](/entities#transient-entities): A type of **Entity** that is not meant to be stored permanently or referenced by other entities.
- **Implementation**: A software application that implements the Versia Protocol. - **Implementation**: A software application that implements the Versia Protocol.
- **Instance**: An application deploying an **Implementation**. - **Instance**: An application deploying an **Implementation**.
- Using the same nomenclature, an ActivityPub Implementation would be `Mastodon`, and an Instance would be `mastodon.social`. - Using the same nomenclature, an ActivityPub Implementation would be `Mastodon`, and an Instance would be `mastodon.social`.

67
app/links/page.mdx Normal file
View file

@ -0,0 +1,67 @@
export const metadata = {
title: 'Versia Links',
description:
'How Versia Links work and how they are used in the Versia Protocol.',
}
# Versia Links
Versia Links are a way to reference entities in the Versia Protocol, in a way that can be handled by browsers and applications. They function the same way as `mailto:` links, but for Versia entities.
## Syntax
<Row>
<Col>
<Properties name="Versia Link">
<Property name="scheme" type="string" required={true}>
Must be `web+versia://` so that browsers and applications can recognize it.
An IANA registration for the `versia://` scheme will be requested in the future.
</Property>
<Property name="action" type="string" required={true}>
Action to take on the entity. Can have multiple slashes as a way to segment the action.
Links that reference instance-specific content (like Entities) **must** reference a Instance.
Possible actions:
- `users/:instance/:id`: Open a user profile.
- `notes/:instance/:id`: Open a note.
- `groups/:instance/:id`: Open a group.
- `reply/:instance/:id`: Open the composer to reply to a note.
- `quote/:instance/:id`: Open the composer to quote a note.
- `share/:instance/:id`: Share a note.
- `compose/:text`: Compose a new note. Text passed at the end will be added in the note compose field. Used for "Share with Versia" buttons on websites.
</Property>
<Property name="instance" type="string" required={false}>
Instance hosting the referenced content, including the port if it is not the default (i.e. `443` for HTTPS).
</Property>
</Properties>
</Col>
<Col sticky>
``` {{ title: "Viewing a user profile" }}
web+versia://users/bob.social/alice
```
``` {{ title: "Viewing a note" }}
web+versia://notes/jimbob.com/01902e09-0f8b-72de-8ee3-9afc0cf5eae1
```
``` {{ title: "Replying to a note" }}
web+versia://reply/bob.social/01902e09-0f8b-72de-8ee3-9afc0cf5eae1
```
</Col>
</Row>
## Handling
Versia clients **should** register themselves as handlers for the `web+versia://` scheme in the user's operating system. When a Versia Link is clicked, the client should open the entity in the client's interface.
The default client ("frontend") on a Versia instance **should** also display Versia links for logged-out users on:
- Profiles
- Notes
- Groups
This **could** be used for easier following of users from remote instances.
Clients **should** ask users to confirm any action that is not a simple view action, such as replying to a note or sharing a note.

View file

@ -1,3 +1,4 @@
import { Guide } from "@/components/Guides";
import { Resource, type ResourceType } from "@/components/Resources"; import { Resource, type ResourceType } from "@/components/Resources";
import { TeamMember } from "@/components/Team"; import { TeamMember } from "@/components/Team";
import { wrapper } from "@/components/mdx"; import { wrapper } from "@/components/mdx";
@ -48,9 +49,9 @@ const Page: FC = () => {
icon: "tabler:database", icon: "tabler:database",
}, },
{ {
name: "In-depth security docs", name: "Tested in production :)",
description: description:
"Docs provide lots of information on how to program a secure instance.", "We know it works well, because we use it in our own projects.",
icon: "tabler:shield", icon: "tabler:shield",
}, },
{ {
@ -98,7 +99,52 @@ const Page: FC = () => {
))} ))}
</div> </div>
<h2 id="team">Team</h2> <h2>Try it out</h2>
<p className="lead">
Use the reference implementation,{" "}
<strong>Versia Server</strong>! It's a microblogging server
with a focus on feeling like current Fediverse platforms,
like Sharkey and Mastodon.
</p>
<Guide
name="Versia Server"
href="https://github.com/versia-pub/server"
description="The reference implementation of the Versia protocol."
/>
<h2>Try a Versia instance</h2>
<p className="lead">
If you want to try out Versia without setting up your own
instance, you can use one of the following public instances:
</p>
<ul>
<li>
<a
href="https://beta.versia.social"
target="_blank"
rel="noopener noreferrer"
>
<code>beta.versia.social</code>
</a>{" "}
(send an email to{" "}
<a
href={`mailto:aprl@versia.pub?subject=${encodeURIComponent("beta.versia.social Account Request")}&body=${encodeURIComponent("Hello, I would like to request an account on beta.versia.social.")}`}
>
<code>aprl@versia.pub</code>
</a>{" "}
for an account)
</li>
</ul>
<h2 id="team">People</h2>
<p className="lead">
You can ask <code>Jesse</code> for help with anything
Versia-related, or if you just want to chat!
</p>
<div className="not-prose mt-4 grid grid-cols-1 max-w-full gap-8 border-t border-zinc-900/5 pt-10 sm:grid-cols-2 xl:grid-cols-3 dark:border-white/5"> <div className="not-prose mt-4 grid grid-cols-1 max-w-full gap-8 border-t border-zinc-900/5 pt-10 sm:grid-cols-2 xl:grid-cols-3 dark:border-white/5">
<TeamMember <TeamMember
@ -125,7 +171,7 @@ const Page: FC = () => {
{ {
name: "Versia", name: "Versia",
icon: "bx:server", icon: "bx:server",
url: "https://social.lysand.org/@jessew", url: "https://beta.versia.social/@jessew",
}, },
{ {
name: "Matrix", name: "Matrix",
@ -135,7 +181,7 @@ const Page: FC = () => {
{ {
name: "Signal", name: "Signal",
icon: "simple-icons:signal", icon: "simple-icons:signal",
url: "https://signal.me/#eu/mdX6iV0ayndNmJst43sNtlw3eFXgHSm7if4Y/mwYT1+qFDzl1PFAeroW+RpHGaRu", url: "https://signal.me/#eu/Qw6gQXvEfcNrgEFgl-KjOBFiF6-3gWSSghgcpSj9dSedVFIPny5NYazioN5t7E24",
}, },
{ {
name: "Email", name: "Email",
@ -163,7 +209,7 @@ const Page: FC = () => {
{ {
name: "Versia", name: "Versia",
icon: "bx:server", icon: "bx:server",
url: "https://social.lysand.org/@aprl", url: "https://beta.versia.social/@aprl",
}, },
{ {
name: "Matrix", name: "Matrix",

View file

@ -0,0 +1,33 @@
# Principles
When designing basically anything, it is very important to define its principles: many projects have failed because they accidentally strayed from their reason to exist. To not repeat the same mistakes, we need to define the core principles of the Versia Protocol.
## Self-description
Entities should contain all the data necessary for their processing (and only theirs) in a single JSON object. This object should also be roughly understandable by a human, and should be able to be serialized and deserialized without loss of information.
This means that headers, transport mechanisms, encoding and other such things should be separate from the data itself. This is to ensure that the data can be processed by any system, regardless of the transport mechanism.
## Determinism
There must always be **one** canonical way of doing any single thing. There must always be **one** way to represent any single piece of data. There should be no ambiguity.
## Simplicity over Functionality
It's better to make something simple and easier to implement than something that supports every single edge case. This is not to say that edge cases should be ignored, but that they should be considered carefully.
In general, **simplicity is preferred over functionality**. Extensions can potentially add fringe functionality back: however, if the core is too complex, it will be hard to add extensions.
When in doubt, **leave it out**.
### Client-facing vs Developer-facing
Client-facing features (such as avatars, emoji reactions, quote posts, etc.) are usually "simpler" than the underlying federation mechanisms: as such, they should be prioritized and may contain more complexity than the underlying mechanisms.
Developer-facing features (e.g. shared inboxes, reply backfilling, etc.) should be as simple as possible, as they are the building blocks of the client-facing features. When considering complexity, **client use-cases are more important than developer use-cases**.
## Branching
There should be **as little branching as possible**. This is reminiscent of the "one canonical way" principle: if there are multiple ways to do something, or a thing can be processed multiple ways, it should be simplified.
If there is a need for branching, nesting should be avoided: branches should be as flat as possible.

View file

@ -17,21 +17,15 @@ Versia uses cryptographic signatures to ensure the integrity and authenticity of
## Signature Definition ## Signature Definition
A signature consists of a series of headers in an HTTP request. The following headers are used: A signature consists of a series of headers in an HTTP request. The following headers are used:
- **`X-Signature`**: The signature itself, encoded in base64. - **`Versia-Signature`**: The signature itself, encoded in base64.
- **`X-Signed-By`**: URI of the user who signed the request, [or the string `instance $1`, to represent the instance, where `$1` is the instance's host](/entities/instance-metadata#the-null-author). - **`Versia-Signed-By`**: URI of the user who signed the request, [or the string `instance $1`, to represent the instance, where `$1` is the instance's host](/entities/instance-metadata#the-null-author).
- **`X-Nonce`**: A random string generated by the client. - **`Versia-Signed-At`**: The current Unix timestamp, in seconds (no milliseconds), when the request was signed. Timezone must be UTC, like all Unix timestamps.
Signatures are **required on ALL federation traffic**. If a request does not have a signature, it **MUST** be rejected. Specifically, signatures must be put on: Signatures are **required on ALL federation traffic**. If a request does not have a signature, it **MUST** be rejected. Specifically, signatures must be put on:
- **All POST requests**. - **All POST requests**.
- **All responses to GET requests** (for example, when fetching a user's profile). In this case, the HTTP method used in the signature string must be `GET`. - **All responses to GET requests** (for example, when fetching a user's profile). In this case, the HTTP method used in the signature string must be `GET`.
<Note> If a signature fails, is missing or is invalid, the instance **MUST** return a `401 Unauthorized` HTTP status code. If the signature timestamp is too old or too new (more than 5 minutes from the current time), the instance **MUST** return a `422 Unprocessable Entity` status code.
Versia's security model makes replay attacks useless, so they are not a concern.
For more information, please read [the security model documentation](/security).
</Note>
If a signature fails, is missing or is invalid, the instance **MUST** return a `401 Unauthorized` HTTP status code.
### Calculating the Signature ### Calculating the Signature
@ -43,21 +37,22 @@ $0 $1 $2 $3
Where: Where:
- `$0` is the HTTP method (e.g. `GET`, `POST`) in lowercase. - `$0` is the HTTP method (e.g. `GET`, `POST`) in lowercase.
- `$1` is the path of the request, in standard URI format (don't forget to URL-encode it). - `$1` is the path of the request, in standard URI format (don't forget to URL-encode it).
- `$2` is the nonce, a random string generated by the client. - `$2` is the Unix timestamp when the request was signed, in UTC seconds.
- `$3` is the SHA-256 hash of the request body, encoded in base64. (if it's a `GET` request, this should be the hash of an empty string) - `$3` is the SHA-256 hash of the request body, encoded in base64. (if it's a `GET` request, this should be the hash of an empty string)
Sign this string using the user's private key. The resulting signature should be encoded in base64. Sign this string using the user's private key. The resulting signature should be encoded in base64.
Example: Example:
``` ```
post /notes a2ebc29eb6762a9164fbcffc9271e8a53562a5e725e7187ea7d88d03cbe59341 n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg= post /notes 1729243417 n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=
``` ```
### Verifying the Signature ### Verifying the Signature
To verify a signature, the instance must: To verify a signature, the instance must:
- Recreate the string as described above. - Recreate the string as described above.
- Extract the signature provided in the `X-Signature` header. - Extract the signature provided in the `Versia-Signature` header.
- Check that the `Versia-Signed-At` timestamp is within 5 minutes of the current time.
- Decode the signature from base64. - Decode the signature from base64.
- Perform a signature verification using the user's public key. - Perform a signature verification using the user's public key.
@ -94,14 +89,14 @@ const privateKey = await crypto.subtle.importKey(
["sign"], ["sign"],
); );
const nonce = crypto.getRandomValues(new Uint8Array(32)) const timestamp = Date.now();
const digest = await crypto.subtle.digest( const digest = await crypto.subtle.digest(
"SHA-256", "SHA-256",
new TextEncoder().encode(content) new TextEncoder().encode(content)
); );
const stringToSign = const stringToSign =
`post /notes ${Buffer.from(nonce).toString("hex")} ${Buffer.from(digest).toString("base64")}`; `post /notes ${timestamp} ${Buffer.from(digest).toString("base64")}`;
const signature = await crypto.subtle.sign( const signature = await crypto.subtle.sign(
"Ed25519", "Ed25519",
@ -117,9 +112,9 @@ To send the request, Bob would use the following code:
```typescript ```typescript
const headers = new Headers(); const headers = new Headers();
headers.set("X-Signed-By", "https://bob.com/users/bf44e6ad-7c0a-4560-9938-cf3fd4066511"); headers.set("Versia-Signed-By", "https://bob.com/users/bf44e6ad-7c0a-4560-9938-cf3fd4066511");
headers.set("X-Nonce", Buffer.from(nonce).toString("hex")); headers.set("Versia-Signed-At", timestamp);
headers.set("X-Signature", base64Signature); headers.set("Versia-Signature", base64Signature);
headers.set("Content-Type", "application/json"); headers.set("Content-Type", "application/json");
const response = await fetch("https://alice.com/notes", { const response = await fetch("https://alice.com/notes", {
@ -134,8 +129,13 @@ On Alice's side, she would verify the signature using Bob's public key. Here, we
```typescript ```typescript
const method = request.method.toLowerCase(); const method = request.method.toLowerCase();
const path = new URL(request.url).pathname; const path = new URL(request.url).pathname;
const signature = request.headers.get("X-Signature"); const signature = request.headers.get("Versia-Signature");
const nonce = request.headers.get("X-Nonce"); const timestamp = request.headers.get("Versia-Signed-At");
// Check if timestamp is within 5 minutes of the current time
if (Math.abs(Date.now() - timestamp) > 300_000) {
return new Response("Timestamp is too old or too new", { status: 422 });
}
const digest = await crypto.subtle.digest( const digest = await crypto.subtle.digest(
"SHA-256", "SHA-256",
@ -143,7 +143,7 @@ const digest = await crypto.subtle.digest(
); );
const stringToVerify = const stringToVerify =
`${method} ${path} ${nonce} ${Buffer.from(digest).toString("base64")}`; `${method} ${path} ${timestamp} ${Buffer.from(digest).toString("base64")}`;
const isVerified = await crypto.subtle.verify( const isVerified = await crypto.subtle.verify(
"Ed25519", "Ed25519",
@ -156,3 +156,27 @@ if (!isVerified) {
return new Response("Signature verification failed", { status: 401 }); return new Response("Signature verification failed", { status: 401 });
} }
``` ```
## Exporting the Public Key
Public keys are always encoded using `base64` and must be in SPKI format. You will need to look up the appropriate method for your cryptographic library to convert the key to this format.
<Note>
This is **not** the same as the key's raw bytes.
This is also not related to the commonly used "PEM" format.
</Note>
```typescript {{ title: "Example using TypeScript and the WebCrypto API" }}
/**
* Using Node.js's Buffer API for brevity
* If using another runtime, you may need to use a different method to convert to/from Base64
*/
const spkiEncodedPublicKey = await crypto.subtle.exportKey(
"spki",
/* Your public key */
publicKey,
);
const base64PublicKey = Buffer.from(publicKey).toString("base64");
```

View file

@ -17,7 +17,7 @@ Pages should be limited to a reasonable number of entities, such as 20 or 80.
<Row> <Row>
<Col> <Col>
<Properties> <Properties name="Collection">
<Property name="author" type="URI | null" required={true} typeLink="/types#uri"> <Property name="author" type="URI | null" required={true} typeLink="/types#uri">
Author of the collection. Usually the user who owns the collection. [Can be set to `null` to represent the instance](/entities/instance-metadata#the-null-author). Author of the collection. Usually the user who owns the collection. [Can be set to `null` to represent the instance](/entities/instance-metadata#the-null-author).
</Property> </Property>
@ -65,6 +65,10 @@ Pages should be limited to a reasonable number of entities, such as 20 or 80.
"type": "Note", "type": "Note",
"uri": "https://versia.social/notes/456df8ed-daf1-4062-abab-491071c7b8dd", "uri": "https://versia.social/notes/456df8ed-daf1-4062-abab-491071c7b8dd",
"created_at": "2024-04-09T01:38:51.743Z", "created_at": "2024-04-09T01:38:51.743Z",
"collections": {
"replies": "https://versia.social/notes/456df8ed-daf1-4062-abab-491071c7b8dd/replies",
"quotes": "https://versia.social/notes/456df8ed-daf1-4062-abab-491071c7b8dd/quotes"
},
"content": { "content": {
"text/plain": { "text/plain": {
"content": "Hello, world!" "content": "Hello, world!"
@ -75,5 +79,62 @@ Pages should be limited to a reasonable number of entities, such as 20 or 80.
} }
``` ```
</Col>
</Row>
## URI Collection
URI Collections are identical to regular collections, but they contain only URIs instead of full entities. They are useful for cases when remote entities need to be included in a collection, as those are typically not stored in implementation databases. {{ className: 'lead' }}
<Row>
<Col>
<Properties name="URICollection">
<Property name="author" type="URI | null" required={true} typeLink="/types#uri">
Author of the collection. Usually the user who owns the collection. [Can be set to `null` to represent the instance](/entities/instance-metadata#the-null-author).
</Property>
<Property name="first" type="URI" required={true} typeLink="/types#uri">
URI to the first page of the collection. Query parameters are allowed.
</Property>
<Property name="last" type="URI" required={true} typeLink="/types#uri">
URI to the last page of the collection. Query parameters are allowed.
If the collection only has one page, this should be the same as `first`.
</Property>
<Property name="total" type="number" required={true} numberType="u64">
Total number of entities in the collection, across all pages.
</Property>
<Property name="next" type="URI" required={false} typeLink="/types#uri">
URI to the next page of the collection. Query parameters are allowed.
If there is no next page, this should be `null`.
</Property>
<Property name="previous" type="URI" required={false} typeLink="/types#uri">
URI to the previous page of the collection. Query parameters are allowed.
If there is no previous page, this should be `null`.
</Property>
<Property name="items" type="URI[]" required={true}>
Collection contents. Must be an array of URIs.
</Property>
</Properties>
</Col>
<Col sticky>
```jsonc {{ 'title': 'Example URI Collection' }}
{
"author": "https://versia.social/users/018ec082-0ae1-761c-b2c5-22275a611771",
"first": "https://versia.social/users/018ec082-0ae1-761c-b2c5-22275a611771/followers?page=1",
"last": "https://versia.social/users/018ec082-0ae1-761c-b2c5-22275a611771/followers?page=3",
"total": 46,
"next": "https://versia.social/users/018ec082-0ae1-761c-b2c5-22275a611771/followers?page=2",
"previous": null,
"items": [
"https://versia.social/users/f8b0d4b4-d354-4798-bbc5-c2ba8acabfe3",
"https://social.bob.com/u/2B27E62snga763"
]
}
```
</Col> </Col>
</Row> </Row>

View file

@ -71,7 +71,7 @@ It is a good idea to provide at least two versions of an image (if possible): on
<Row> <Row>
<Col> <Col>
<Properties> <Properties name="ContentFormat">
<Property name="content" type="string | URI" required={true}> <Property name="content" type="string | URI" required={true}>
Structure data. If `Content-Type` is a binary format, this field should be a URI to the binary data. Otherwise, it should be the content itself. Refer to the `remote` property for more information. Structure data. If `Content-Type` is a binary format, this field should be a URI to the binary data. Otherwise, it should be the content itself. Refer to the `remote` property for more information.
</Property> </Property>

View file

@ -1,23 +1,7 @@
## ISO8601 ## RFC3339
```typescript [https://datatracker.ietf.org/doc/html/rfc3339](https://datatracker.ietf.org/doc/html/rfc3339)
type Year = `${number}${number}${number}${number}`;
type Month = `${"0" | "1"}${number}`;
type Day = `${"0" | "1" | "2" | "3"}${number}`;
type DateString = `${Year}-${Month}-${Day}`;
type Hour = `${"0" | "1" | "2"}${number}`;
type Minute = `${"0" | "1" | "2" | "3" | "4" | "5"}${number}`;
type Second = `${"0" | "1" | "2" | "3" | "4" | "5"}${number}`;
type TimeString = `${Hour}:${Minute}:${Second}`;
type Offset = `${"Z" | "+" | "-"}${Hour}:${Minute}`;
type ISO8601 = `${DateString}T${TimeString}${Offset}`;
```
## UUID ## UUID

View file

@ -1,5 +1,5 @@
{ {
"$schema": "https://biomejs.dev/schemas/1.9.2/schema.json", "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"organizeImports": { "organizeImports": {
"enabled": true "enabled": true
}, },
@ -87,6 +87,6 @@
"globals": ["Bun"] "globals": ["Bun"]
}, },
"files": { "files": {
"ignore": ["node_modules", ".next", ".output", "out"] "ignore": ["node_modules", ".next", ".output", "out", "public/pl.js"]
} }
} }

BIN
bun.lockb

Binary file not shown.

View file

@ -144,7 +144,8 @@ function CodePanel({
label?: string; label?: string;
code?: string; code?: string;
}) { }) {
const child = Children.only(children); // biome-ignore lint/suspicious/noExplicitAny: <explanation>
const child = Children.only(children) as ReactNode & { props: any };
if (isValidElement(child)) { if (isValidElement(child)) {
tag = child.props.tag ?? tag; tag = child.props.tag ?? tag;
@ -205,7 +206,10 @@ function CodeGroupHeader({
)} )}
> >
{getPanelTitle( {getPanelTitle(
isValidElement(child) ? child.props : {}, isValidElement(child)
? // biome-ignore lint/suspicious/noExplicitAny: <explanation>
(child.props as any)
: {},
)} )}
</Tab> </Tab>
))} ))}
@ -238,7 +242,7 @@ function CodeGroupPanels({
function usePreventLayoutShift() { function usePreventLayoutShift() {
const positionRef = useRef<HTMLElement>(null); const positionRef = useRef<HTMLElement>(null);
const rafRef = useRef<number>(); const rafRef = useRef<number | undefined>(undefined);
useEffect(() => { useEffect(() => {
return () => { return () => {
@ -322,7 +326,8 @@ export function CodeGroup({
}: ComponentPropsWithoutRef<typeof CodeGroupPanels> & { title: string }) { }: ComponentPropsWithoutRef<typeof CodeGroupPanels> & { title: string }) {
const languages = const languages =
Children.map(children, (child) => Children.map(children, (child) =>
getPanelTitle(isValidElement(child) ? child.props : {}), // biome-ignore lint/suspicious/noExplicitAny: <explanation>
getPanelTitle(isValidElement(child) ? (child.props as any) : {}),
) ?? []; ) ?? [];
const tabGroupProps = useTabGroupProps(languages); const tabGroupProps = useTabGroupProps(languages);
const hasTabs = Children.count(children) > 1; const hasTabs = Children.count(children) > 1;

View file

@ -51,6 +51,7 @@ export const Header = forwardRef<ElementRef<"div">, { className?: string }>(
ref={ref} ref={ref}
className={clsx( className={clsx(
className, className,
// Add bg-construction bg-opacity-10 [background-size:57px_57px] classes to make it striped
"fixed inset-x-0 top-0 z-50 flex h-14 items-center justify-between gap-2 px-4 transition sm:px-6 lg:left-72 lg:z-30 lg:px-8 xl:left-80", "fixed inset-x-0 top-0 z-50 flex h-14 items-center justify-between gap-2 px-4 transition sm:px-6 lg:left-72 lg:z-30 lg:px-8 xl:left-80",
!isInsideMobileNavigation && !isInsideMobileNavigation &&
"backdrop-blur-sm lg:left-72 xl:left-80 dark:backdrop-blur", "backdrop-blur-sm lg:left-72 xl:left-80 dark:backdrop-blur",
@ -94,7 +95,7 @@ export const Header = forwardRef<ElementRef<"div">, { className?: string }>(
<ThemeToggle /> <ThemeToggle />
</div> </div>
<div className="hidden min-[500px]:contents"> <div className="hidden min-[500px]:contents">
<Button href="/changelog">Working Draft 4</Button> <Button href="/changelog">Working Draft 5</Button>
</div> </div>
</div> </div>
</motion.div> </motion.div>

View file

@ -5,6 +5,7 @@ import Link from "next/link";
import { import {
type ComponentPropsWithoutRef, type ComponentPropsWithoutRef,
type ReactNode, type ReactNode,
type RefObject,
useEffect, useEffect,
useRef, useRef,
} from "react"; } from "react";
@ -13,7 +14,7 @@ import { remToPx } from "../lib/remToPx";
import { useSectionStore } from "./SectionProvider"; import { useSectionStore } from "./SectionProvider";
import { Tag } from "./Tag"; import { Tag } from "./Tag";
function AnchorIcon(props: ComponentPropsWithoutRef<"svg">) { export function AnchorIcon(props: ComponentPropsWithoutRef<"svg">) {
return ( return (
<svg <svg
viewBox="0 0 20 20" viewBox="0 0 20 20"
@ -87,7 +88,7 @@ export function Heading<Level extends 2 | 3>({
}) { }) {
level = level ?? (2 as Level); level = level ?? (2 as Level);
const Component = `h${level}` as "h2" | "h3"; const Component = `h${level}` as "h2" | "h3";
const ref = useRef<HTMLHeadingElement>(null); const ref = useRef<HTMLHeadingElement | null>(null);
const registerHeading = useSectionStore((s) => s.registerHeading); const registerHeading = useSectionStore((s) => s.registerHeading);
const inView = useInView(ref, { const inView = useInView(ref, {
@ -96,10 +97,10 @@ export function Heading<Level extends 2 | 3>({
}); });
useEffect(() => { useEffect(() => {
if (level === 2) { if (level === 2 && ref.current) {
registerHeading({ registerHeading({
id: props.id, id: props.id,
ref, ref: ref as RefObject<HTMLHeadingElement>,
offsetRem: tag || label ? 8 : 6, offsetRem: tag || label ? 8 : 6,
}); });
} }

View file

@ -1,5 +1,7 @@
import clsx from "clsx"; import clsx from "clsx";
import type { ComponentPropsWithoutRef } from "react"; import type { ComponentPropsWithoutRef } from "react";
// Uncomment this on dev branches
// import { Badge } from "./Metadata";
export function Logo(props: ComponentPropsWithoutRef<"div">) { export function Logo(props: ComponentPropsWithoutRef<"div">) {
return ( return (
@ -16,7 +18,7 @@ export function Logo(props: ComponentPropsWithoutRef<"div">) {
className="h-full rounded-sm" className="h-full rounded-sm"
/> />
<span className="fill-zinc-900 dark:fill-white font-semibold text-lg"> <span className="fill-zinc-900 dark:fill-white font-semibold text-lg">
Versia Protocol Versia Protocol {/* <Badge className="!h-6">Dev</Badge> */}
</span> </span>
</div> </div>
); );

View file

@ -209,6 +209,7 @@ function NavigationGroup({
{link.href === pathname && {link.href === pathname &&
sections.length > 0 && ( sections.length > 0 && (
<motion.ul <motion.ul
// biome-ignore lint/a11y/useSemanticElements: already a <ul>
role="list" role="list"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ animate={{
@ -252,9 +253,14 @@ export const navigation: NavGroup[] = [
{ title: "Signatures", href: "/signatures" }, { title: "Signatures", href: "/signatures" },
{ title: "Security", href: "/security" }, { title: "Security", href: "/security" },
{ title: "Federation", href: "/federation" }, { title: "Federation", href: "/federation" },
{ title: "Links", href: "/links" },
{ title: "Extensions", href: "/extensions" }, { title: "Extensions", href: "/extensions" },
], ],
}, },
{
title: "Philosophy",
links: [{ title: "Principles", href: "/philosophy/principles" }],
},
{ {
title: "Federation", title: "Federation",
links: [ links: [
@ -262,6 +268,7 @@ export const navigation: NavGroup[] = [
{ title: "Validation", href: "/federation/validation" }, { title: "Validation", href: "/federation/validation" },
{ title: "Discovery", href: "/federation/discovery" }, { title: "Discovery", href: "/federation/discovery" },
{ title: "Delegation", href: "/federation/delegation" }, { title: "Delegation", href: "/federation/delegation" },
{ title: "Example", href: "/federation/example" },
], ],
}, },
{ {
@ -278,7 +285,6 @@ export const navigation: NavGroup[] = [
{ title: "Follow", href: "/entities/follow" }, { title: "Follow", href: "/entities/follow" },
{ title: "FollowAccept", href: "/entities/follow-accept" }, { title: "FollowAccept", href: "/entities/follow-accept" },
{ title: "FollowReject", href: "/entities/follow-reject" }, { title: "FollowReject", href: "/entities/follow-reject" },
{ title: "Group", href: "/entities/group" },
{ title: "Notes", href: "/entities/note" }, { title: "Notes", href: "/entities/note" },
{ title: "InstanceMetadata", href: "/entities/instance-metadata" }, { title: "InstanceMetadata", href: "/entities/instance-metadata" },
{ title: "Unfollow", href: "/entities/unfollow" }, { title: "Unfollow", href: "/entities/unfollow" },
@ -289,10 +295,15 @@ export const navigation: NavGroup[] = [
title: "Extensions", title: "Extensions",
links: [ links: [
{ title: "Custom Emojis", href: "/extensions/custom-emojis" }, { title: "Custom Emojis", href: "/extensions/custom-emojis" },
{ title: "Groups", href: "/extensions/groups" },
{ {
title: "Instance Messaging", title: "Instance Messaging",
href: "/extensions/instance-messaging", href: "/extensions/instance-messaging",
}, },
{
title: "Interaction Controls",
href: "/extensions/interaction-controls",
},
{ title: "Likes", href: "/extensions/likes" }, { title: "Likes", href: "/extensions/likes" },
{ title: "Migration", href: "/extensions/migration" }, { title: "Migration", href: "/extensions/migration" },
{ title: "Polls", href: "/extensions/polls" }, { title: "Polls", href: "/extensions/polls" },
@ -323,7 +334,7 @@ export function Navigation(props: ComponentPropsWithoutRef<"nav">) {
variant="filled" variant="filled"
className="w-full" className="w-full"
> >
Working Draft 4 Working Draft 5
</Button> </Button>
</li> </li>
</ul> </ul>

116
components/Property.tsx Normal file
View file

@ -0,0 +1,116 @@
"use client";
import Link from "next/link";
import { type ReactNode, createContext, useContext } from "react";
import { AnchorIcon } from "./Heading";
export const PropertyContext = createContext<{
name: string;
}>({
name: "",
});
export function Properties({
children,
name,
}: { children: ReactNode; name: string }) {
return (
<div className="my-6">
<ul className="m-0 max-w-[calc(theme(maxWidth.lg)-theme(spacing.8))] list-none divide-y divide-zinc-900/5 p-0 dark:divide-white/5">
<PropertyContext.Provider value={{ name }}>
{children}
</PropertyContext.Provider>
</ul>
</div>
);
}
const numberTypeTooltips = {
f64: "64-bit floating-point number",
i64: "64-bit signed integer",
u64: "64-bit unsigned integer",
};
export function Property({
name,
children,
type,
typeLink,
numberType,
required,
}: {
name: string;
children: ReactNode;
type?: string;
typeLink?: string;
numberType?: "f64" | "i64" | "u64";
required?: boolean;
}) {
const { name: contextName } = useContext(PropertyContext);
const idFormat = (name: string) =>
name
.toLowerCase()
.replace(/[^a-z0-9]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
const id = `${idFormat(contextName)}-${idFormat(name)}`;
return (
<li className="m-0 px-0 py-4 first:pt-0 last:pb-0 group">
<Link
href={`#${id}`}
className="absolute ml-[calc(-1*var(--width))] mt-1 hidden w-[var(--width)] opacity-0 transition [--width:calc(2.625rem+0.5px+50%-min(50%,calc(theme(maxWidth.lg)+theme(spacing.8))))] group-hover:opacity-100 group-focus:opacity-100 md:block lg:z-50 2xl:[--width:theme(spacing.10)]"
>
<div className="group/anchor block h-5 w-5 rounded bg-zinc-50 ring-1 ring-inset ring-zinc-300 transition hover:ring-zinc-500 dark:bg-zinc-800 dark:ring-zinc-700 dark:hover:bg-zinc-700 dark:hover:ring-zinc-600">
<AnchorIcon className="h-5 w-5 stroke-zinc-500 transition dark:stroke-zinc-400 dark:group-hover/anchor:stroke-white" />
</div>
</Link>
<dl
id={id}
className="m-0 flex flex-wrap items-center gap-x-3 gap-y-2 scroll-mt-24 target:ring-2 ring-brand-500 ring-offset-8 dark:ring-offset-zinc-900"
>
<dt className="sr-only">Name</dt>
<dd>
<code>{name}</code>
</dd>
{required && (
<>
<dt className="sr-only">Required</dt>
<dd className="inline-flex items-center rounded-md bg-brand-50 px-2 py-0 text-xs font-medium text-brand-700 ring-1 ring-inset ring-brand-500/10 dark:bg-brand-500/10 dark:text-brand-100 dark:ring-brand-200/20">
Required
</dd>
</>
)}
{numberType && (
<>
<dt className="sr-only">Type</dt>
<dd
className="inline-flex items-center rounded-md bg-blue-50 px-2 py-0 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-500/10 dark:bg-blue-500/10 dark:text-blue-100 dark:ring-blue-200/20 hover:cursor-pointer"
title={numberTypeTooltips[numberType]}
>
{numberType}
</dd>
</>
)}
{type && (
<>
<dt className="sr-only">Type</dt>
<dd className="font-mono text-xs text-zinc-400 dark:text-zinc-500">
{typeLink ? (
<Link href={typeLink}>{type}</Link>
) : (
type
)}
</dd>
</>
)}
<dt className="sr-only">Description</dt>
<dd className="w-full flex-none [&>:first-child]:mt-0 [&>:last-child]:mb-0">
{children}
</dd>
</dl>
</li>
);
}

View file

@ -164,6 +164,7 @@ function LoadingIcon(props: ComponentPropsWithoutRef<"svg">) {
function HighlightQuery({ text, query }: { text: string; query: string }) { function HighlightQuery({ text, query }: { text: string; query: string }) {
return ( return (
// @ts-expect-error types not properly updated to react 19
<Highlighter <Highlighter
highlightClassName="underline bg-transparent text-brand-500" highlightClassName="underline bg-transparent text-brand-500"
searchWords={[query]} searchWords={[query]}

View file

@ -9,6 +9,7 @@ export const a = Link;
// biome-ignore lint/performance/noBarrelFile: <explanation> // biome-ignore lint/performance/noBarrelFile: <explanation>
export { Button } from "./Button"; export { Button } from "./Button";
export { CodeGroup, Code as code, Pre as pre } from "./Code"; export { CodeGroup, Code as code, Pre as pre } from "./Code";
export { Property, Properties } from "./Property";
export function wrapper({ children }: { children: ReactNode }) { export function wrapper({ children }: { children: ReactNode }) {
return ( return (
@ -80,81 +81,3 @@ export function Col({
</div> </div>
); );
} }
export function Properties({ children }: { children: ReactNode }) {
return (
<div className="my-6">
<ul className="m-0 max-w-[calc(theme(maxWidth.lg)-theme(spacing.8))] list-none divide-y divide-zinc-900/5 p-0 dark:divide-white/5">
{children}
</ul>
</div>
);
}
const numberTypeTooltips = {
f64: "64-bit floating-point number",
i64: "64-bit signed integer",
u64: "64-bit unsigned integer",
};
export function Property({
name,
children,
type,
typeLink,
numberType,
required,
}: {
name: string;
children: ReactNode;
type?: string;
typeLink?: string;
numberType?: "f64" | "i64" | "u64";
required?: boolean;
}) {
return (
<li className="m-0 px-0 py-4 first:pt-0 last:pb-0">
<dl className="m-0 flex flex-wrap items-center gap-x-3 gap-y-2">
<dt className="sr-only">Name</dt>
<dd>
<code>{name}</code>
</dd>
{required && (
<>
<dt className="sr-only">Required</dt>
<dd className="inline-flex items-center rounded-md bg-brand-50 px-2 py-0 text-xs font-medium text-brand-700 ring-1 ring-inset ring-brand-500/10 dark:bg-brand-500/10 dark:text-brand-100 dark:ring-brand-200/20">
Required
</dd>
</>
)}
{numberType && (
<>
<dt className="sr-only">Type</dt>
<dd
className="inline-flex items-center rounded-md bg-blue-50 px-2 py-0 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-500/10 dark:bg-blue-500/10 dark:text-blue-100 dark:ring-blue-200/20 hover:cursor-pointer"
title={numberTypeTooltips[numberType]}
>
{numberType}
</dd>
</>
)}
{type && (
<>
<dt className="sr-only">Type</dt>
<dd className="font-mono text-xs text-zinc-400 dark:text-zinc-500">
{typeLink ? (
<Link href={typeLink}>{type}</Link>
) : (
type
)}
</dd>
</>
)}
<dt className="sr-only">Description</dt>
<dd className="w-full flex-none [&>:first-child]:mt-0 [&>:last-child]:mb-0">
{children}
</dd>
</dl>
</li>
);
}

View file

@ -31,6 +31,7 @@ const highlighter = await createHighlighter({
"html", "html",
"json5", "json5",
"jsonc", "jsonc",
"markdown",
"bash", "bash",
"php", "php",
"python", "python",

View file

@ -11,49 +11,53 @@
"uuid": "bun --print 'crypto.randomUUID()'" "uuid": "bun --print 'crypto.randomUUID()'"
}, },
"dependencies": { "dependencies": {
"@algolia/autocomplete-core": "^1.17.4", "@algolia/autocomplete-core": "^1.17.9",
"@headlessui/react": "^2.1.8", "@headlessui/react": "^2.2.0",
"@headlessui/tailwindcss": "^0.2.1", "@headlessui/tailwindcss": "^0.2.1",
"@mdx-js/loader": "^3.0.1", "@mdx-js/loader": "^3.1.0",
"@mdx-js/react": "^3.0.1", "@mdx-js/react": "^3.1.0",
"@next/mdx": "^14.2.13", "@next/mdx": "^15.1.5",
"@sindresorhus/slugify": "^2.2.1", "@sindresorhus/slugify": "^2.2.1",
"@tailwindcss/typography": "^0.5.15", "@tailwindcss/typography": "^0.5.16",
"@types/mdx": "^2.0.13", "@types/mdx": "^2.0.13",
"@types/node": "^22.7.4", "@types/node": "^22.10.7",
"@types/react": "^18.3.10", "@types/react": "^19.0.7",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^19.0.3",
"@types/react-highlight-words": "^0.20.0", "@types/react-highlight-words": "^0.20.0",
"acorn": "^8.12.1", "acorn": "^8.14.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"fast-glob": "^3.3.2", "fast-glob": "^3.3.3",
"flexsearch": "^0.7.43", "flexsearch": "^0.7.43",
"framer-motion": "^11.9.0", "framer-motion": "^11.18.1",
"mdast-util-to-string": "^4.0.0", "mdast-util-to-string": "^4.0.0",
"mdx-annotations": "^0.1.4", "mdx-annotations": "^0.1.4",
"next": "^14.2.13", "next": "^15.1.5",
"next-themes": "^0.3.0", "next-themes": "^0.4.4",
"react": "^18.3.1", "react": "^19.0.0",
"react-dom": "^18.3.1", "react-dom": "^19.0.0",
"react-highlight-words": "^0.20.0", "react-highlight-words": "^0.21.0",
"remark": "^15.0.1", "remark": "^15.0.1",
"remark-gfm": "^4.0.0", "remark-gfm": "^4.0.0",
"remark-mdx": "^3.0.1", "remark-mdx": "^3.1.0",
"shiki": "^1.20.0", "shiki": "^1.27.2",
"simple-functional-loader": "^1.2.1", "simple-functional-loader": "^1.2.1",
"tailwindcss": "^3.4.13", "tailwindcss": "^3.4.17",
"typescript": "^5.6.2", "typescript": "^5.7.3",
"unist-util-filter": "^5.0.1", "unist-util-filter": "^5.0.1",
"unist-util-visit": "^5.0.0", "unist-util-visit": "^5.0.0",
"zustand": "^5.0.0-rc.2" "zustand": "^5.0.3"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^1.9.2", "@biomejs/biome": "^1.9.4",
"@iconify-icon/react": "^2.1.0", "@iconify-icon/react": "^2.3.0",
"@next/bundle-analyzer": "^14.2.13", "@next/bundle-analyzer": "^15.1.5",
"@shikijs/transformers": "^1.20.0", "@shikijs/transformers": "^1.27.2",
"sharp": "^0.33.5" "sharp": "^0.33.5"
}, },
"overrides": {
"react": "^19.0.0-rc-91061073-20241121",
"react-dom": "^19.0.0-rc-91061073-20241121"
},
"trustedDependencies": ["@biomejs/biome", "sharp"] "trustedDependencies": ["@biomejs/biome", "sharp"]
} }

View file

@ -55,6 +55,10 @@ export default {
animation: { animation: {
roll: "roll 2s 1 ease-in-out", roll: "roll 2s 1 ease-in-out",
}, },
backgroundImage: {
construction:
"linear-gradient(45deg, rgb(255 195 0 / var(--tw-bg-opacity)) 25%, rgb(46 39 37 / var(--tw-bg-opacity)) 25%, rgb(46 39 37 / var(--tw-bg-opacity)) 50%, rgb(255 195 0 / var(--tw-bg-opacity)) 50%, rgb(255 195 0 / var(--tw-bg-opacity)) 75%, rgb(46 39 37 / var(--tw-bg-opacity)) 75%, rgb(46 39 37 / var(--tw-bg-opacity)) 100%)",
},
keyframes: { keyframes: {
roll: { roll: {
"0%": { transform: "rotate(0deg)" }, "0%": { transform: "rotate(0deg)" },