diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml new file mode 100644 index 0000000..1dba954 --- /dev/null +++ b/.github/workflows/mirror.yml @@ -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 diff --git a/app/changelog/page.mdx b/app/changelog/page.mdx index 72ecd94..ac5a2fa 100644 --- a/app/changelog/page.mdx +++ b/app/changelog/page.mdx @@ -6,9 +6,35 @@ export const metadata = { # 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. - Moved Likes and Dislikes to an extension. diff --git a/app/entities/delete/page.mdx b/app/entities/delete/page.mdx index b8521df..ba9b3f6 100644 --- a/app/entities/delete/page.mdx +++ b/app/entities/delete/page.mdx @@ -5,7 +5,7 @@ export const metadata = { # 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 @@ -19,9 +19,9 @@ Having the authorization is defined as: - + - This entity does not have a URI. + This is a [**Transient Entity**](/entities#transient-entities) and does not have a 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). diff --git a/app/entities/follow-accept/page.mdx b/app/entities/follow-accept/page.mdx index 1748d3d..103b4b0 100644 --- a/app/entities/follow-accept/page.mdx +++ b/app/entities/follow-accept/page.mdx @@ -13,9 +13,9 @@ export const metadata = { - + - This entity does not have a URI. + This is a [**Transient Entity**](/entities#transient-entities) and does not have a URI. URI of the `User` considered the 'followee', i.e. the user who is being followed. diff --git a/app/entities/follow-reject/page.mdx b/app/entities/follow-reject/page.mdx index bfba74b..7492789 100644 --- a/app/entities/follow-reject/page.mdx +++ b/app/entities/follow-reject/page.mdx @@ -23,9 +23,9 @@ But it can also be used when Bob is already following Alice, in the case that: - + - This entity does not have a URI. + This is a [**Transient Entity**](/entities#transient-entities) and does not have a URI. URI of the `User` considered the 'followee', i.e. the user who is being followed. diff --git a/app/entities/follow/page.mdx b/app/entities/follow/page.mdx index 179200b..7287542 100644 --- a/app/entities/follow/page.mdx +++ b/app/entities/follow/page.mdx @@ -55,9 +55,9 @@ Once a follow relationship is established, the **followee**'s instance should se - + - This entity does not have a URI. + This is a [**Transient Entity**](/entities#transient-entities) and does not have a URI. URI of the `User` considered the 'follower'. diff --git a/app/entities/group/page.mdx b/app/entities/group/page.mdx deleted file mode 100644 index ec9e4cc..0000000 --- a/app/entities/group/page.mdx +++ /dev/null @@ -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 - - - - - - Group name/title. - - Text only (`text/plain`, `text/html`, etc). - - - Short description of the group's contents and purpose. - - Text only (`text/plain`, `text/html`, etc). - - - URI of the group's members list. [Collection](/structures/collection) of [Users](/entities/user). - - - URI of the group's associated notes. [Collection](/structures/collection) of [Notes](/entities/note). - - - - - - - ```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 Woozy 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", - } - ``` - - - \ No newline at end of file diff --git a/app/entities/instance-metadata/page.mdx b/app/entities/instance-metadata/page.mdx index 07b3a5e..4232622 100644 --- a/app/entities/instance-metadata/page.mdx +++ b/app/entities/instance-metadata/page.mdx @@ -17,7 +17,7 @@ Check the entity's documentation page to see if it supports this (it will be not - + This entity does not have an ID. @@ -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. - - `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). 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": { "versions": [ "0.3.0", - "0.4.0" + "0.4.0", + "0.5.0" ], "extensions": [ "pub.versia:reactions", diff --git a/app/entities/note/page.mdx b/app/entities/note/page.mdx index 7a3f363..2227f4c 100644 --- a/app/entities/note/page.mdx +++ b/app/entities/note/page.mdx @@ -15,7 +15,7 @@ Notes represent a piece of content on a Versia instance. They can be posted by [ - + Media attachments to the note. May be any format. **Must** be remote. @@ -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 ``` + + 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. + The content of the note. Must be text format (`text/html`, `text/markdown`, etc). Must not be remote. @@ -51,11 +73,15 @@ Notes represent a piece of content on a Versia instance. They can be posted by [ ``` - 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. - `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. + + + If the implementation does not support the [Groups Extension](/extensions/groups), any value other than `public` or `followers` should be treated as `null`. + 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", "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": { "text/html": { "content": "

In the next versia-fe update: account settings, finally!

" diff --git a/app/entities/page.mdx b/app/entities/page.mdx index fb036a2..b4bb253 100644 --- a/app/entities/page.mdx +++ b/app/entities/page.mdx @@ -17,15 +17,15 @@ Any field in an entity not marked as `required` may be omitted or set to `null`. - + Unique identifier for the entity. Must be unique within the instance. Can be any string. Max of 512 UTF-8 characters. Type of the entity. Custom types must follow [Extension Naming](/extensions#naming). - - 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. 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`. 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.** + + [**Transient Entities**](/entities#transient-entities) do not require a URI. + + + + URL of any JSON Schema that the entity adheres to. + + + This is for human use only, and not to be used by either clients or servers as a way to validate 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`. +## 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 -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. -- 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 use Unix-style `\n` line endings (LF). \ No newline at end of file diff --git a/app/entities/unfollow/page.mdx b/app/entities/unfollow/page.mdx index fe51cff..a155516 100644 --- a/app/entities/unfollow/page.mdx +++ b/app/entities/unfollow/page.mdx @@ -28,9 +28,9 @@ Sometimes, [Users](/entities/user) want to unsubscribe from each other to stop s - + - This entity does not have a URI. + This is a [**Transient Entity**](/entities#transient-entities) and does not have a URI. URI of the `User` considered the 'follower', i.e. the user who is unsubscribing from the followee. diff --git a/app/entities/user/page.mdx b/app/entities/user/page.mdx index f7a6ab8..f3141d1 100644 --- a/app/entities/user/page.mdx +++ b/app/entities/user/page.mdx @@ -23,7 +23,7 @@ For example: @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 @@ -42,7 +42,7 @@ Instance **must** be the host of the instance the user is on (hostname with opti - + The user's avatar. Must be an image format (`image/*`). @@ -65,7 +65,7 @@ Instance **must** be the host of the instance the user is on (hostname with opti 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. 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)). - `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. ```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 - 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 - 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 - 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 - [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. diff --git a/app/extensions/custom-emojis/page.mdx b/app/extensions/custom-emojis/page.mdx index cf491ed..f6d3a18 100644 --- a/app/extensions/custom-emojis/page.mdx +++ b/app/extensions/custom-emojis/page.mdx @@ -11,7 +11,7 @@ The Custom Emojis extension adds support for adding personalized emojis to feder - + 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 - + [Custom emojis](/extensions/custom-emoji#structure-definition) to be added to the note. @@ -87,6 +87,10 @@ Custom Emojis can be added to any entity with text content. The extension ID is "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:!" diff --git a/app/extensions/groups/page.mdx b/app/extensions/groups/page.mdx new file mode 100644 index 0000000..5a41f78 --- /dev/null +++ b/app/extensions/groups/page.mdx @@ -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 + + + + + + Must be `pub.versia:groups/Group`. + + + Group name/title. + + Text only (`text/plain`, `text/html`, etc). + + + Short description of the group's contents and purpose. + + Text only (`text/plain`, `text/html`, etc). + + + Whether the group is open to all users or requires approval to join. + + + **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. + + + + URI of the group's members list. [URI Collection](/structures/collection#uri-collection) of [Users](/entities/user). + + + URI of the group's associated notes. [URI Collection](/structures/collection#uri-collection) of [Notes](/entities/note). + + + + + + + ```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 Woozy 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", + } + ``` + + + + +## 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. + + + + + + Must be `pub.versia:groups/Subscribe`. + + + This is a [**Transient Entity**](/entities#transient-entities) and does not have a URI. + + + URI of the [User](/entities/user) subscribing to the group. + + + URI of the group to subscribe to. + + + + + + + ```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" + } + ``` + + + + +### GroupUnsubscribe + +Indicates that a [User](/entities/user) wishes to unsubscribe from a group. + + + + + + Must be `pub.versia:groups/Unsubscribe`. + + + This is a [**Transient Entity**](/entities#transient-entities) and does not have a URI. + + + URI of the [User](/entities/user) unsubscribing from the group. + + + URI of the group to unsubscribe from. + + + + + + + ```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" + } + ``` + + + + +### 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. + + + + + + Must be `pub.versia:groups/SubscribeAccept`. + + + This is a [**Transient Entity**](/entities#transient-entities) and does not have a URI. + + + URI of the [User](/entities/user) subscribing to the group. + + + URI of the group that accepted the subscription. + + + + + + + ```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" + } + ``` + + + + +### 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. + + + + + + Must be `pub.versia:groups/SubscribeReject`. + + + This is a [**Transient Entity**](/entities#transient-entities) and does not have a URI. + + + URI of the [User](/entities/user) subscribing to the group. + + + URI of the group that rejected the subscription. + + + + + + + ```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" + } + ``` + + + + +## 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. + + + + + + Must be `pub.versia:groups/Federate`. + + + This is a [**Transient Entity**](/entities#transient-entities) and does not have a URI. + + + URI of the note to federate. + + + URI of the group federating the note. + + + + + + + ```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" + } + ``` + + + \ No newline at end of file diff --git a/app/extensions/instance-messaging/page.mdx b/app/extensions/instance-messaging/page.mdx index 7453d04..794d1a7 100644 --- a/app/extensions/instance-messaging/page.mdx +++ b/app/extensions/instance-messaging/page.mdx @@ -29,7 +29,7 @@ This extension adds the following metadata to instances: - + The endpoint to send federation debug messages to. @@ -64,7 +64,7 @@ This extension adds the following metadata to instances: }, "compatibility": { "versions": [ - "0.4.0" + "0.5.0" ], "extensions": [ "pub.versia:reactions", diff --git a/app/extensions/interaction-controls/page.mdx b/app/extensions/interaction-controls/page.mdx new file mode 100644 index 0000000..96bbeea --- /dev/null +++ b/app/extensions/interaction-controls/page.mdx @@ -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 + + + + + + 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. + + + + + + ```jsonc {{ title: "Example"}} + { + "reply": { + "allowed": [ + "group" + ], + }, + "pub.versia:likes#Like": { + "disallowed": [ + "everyone" + ] + } + } + ``` + + + +## 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. + + + To avoid server load from constant Collection refreshing, implementations **could** only refetch associated Collections when forbidden interactions are detected, then recalculate permissions again. + \ No newline at end of file diff --git a/app/extensions/likes/page.mdx b/app/extensions/likes/page.mdx index ee49ea4..403e6e7 100644 --- a/app/extensions/likes/page.mdx +++ b/app/extensions/likes/page.mdx @@ -18,7 +18,7 @@ Likes are a way for users to show appreciation for a note, like Twitter's "heart - + Must be `pub.versia:likes/Like`. @@ -55,7 +55,7 @@ Dislikes are a way for users to show disapproval for a note, like YouTube's "dis - + Must be `pub.versia:likes/Dislike`. @@ -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: -- `likes`: A [Collection](/structures/collection) of all the notes the user has liked. -- `dislikes`: A [Collection](/structures/collection) of all the notes the user has disliked. +- `likes`: A [URI Collection](/structures/collection#uri-collection) of all the notes the user has liked. +- `dislikes`: A [URI Collection](/structures/collection#uri-collection) of all the notes the user has disliked. ```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/Dislikes": "https://example.com/users/6e0204a2-746c-4972-8602-c4f37fc63bbe/dislikes" } -} \ No newline at end of file +} +``` + +## 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 + + + This section only applies to implementors of the [Interaction Controls Extension](/extensions/interaction-controls). + + +This extension registers the following interaction types: +- `pub.versia:likes#Like`, for liking a Note +- `pub.versia:likes#Dislike`, for disliking a Note \ No newline at end of file diff --git a/app/extensions/migration/page.mdx b/app/extensions/migration/page.mdx index 32e5e19..b52376b 100644 --- a/app/extensions/migration/page.mdx +++ b/app/extensions/migration/page.mdx @@ -32,9 +32,9 @@ Migration happens in three steps: - + - This entity does not have a URI. + This is a [**Transient Entity**](/entities#transient-entities) and does not have a URI. Must be `pub.versia:migration/Migration`. @@ -69,7 +69,7 @@ The following extensions to [User](/entities/user) are used by the migration ext - + If this user has migrated from another instance, this property **MUST** be set to the URI of the user on the previous instance. diff --git a/app/extensions/page.mdx b/app/extensions/page.mdx index 54b6b6e..d3ba1f3 100644 --- a/app/extensions/page.mdx +++ b/app/extensions/page.mdx @@ -44,7 +44,7 @@ Extensions can be found in two places: an [Entity](/entities#entity-definition)' - + 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" }} { - "type": "Group", + "type": "pub.versia:groups/Group", "id": "ed480922-b095-4f09-9da5-c995be8f5960", "uri": "https://example.com/groups/ed480922-b095-4f09-9da5-c995be8f5960", "name": null, @@ -85,7 +85,7 @@ Extensions can be found in two places: an [Entity](/entities#entity-definition)' - + The extension type. [Must follow naming conventions](#naming). diff --git a/app/extensions/polls/page.mdx b/app/extensions/polls/page.mdx index dc048d2..cb671a7 100644 --- a/app/extensions/polls/page.mdx +++ b/app/extensions/polls/page.mdx @@ -19,7 +19,7 @@ Note that there is no `question` field: the question should be included in the ` - + 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). @@ -29,8 +29,8 @@ Note that there is no `question` field: the question should be included in the ` Whether the poll allows multiple votes to be cast for different options. - - 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. @@ -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", "author": "https://versia.social/users/018eb863-753f-76ff-83d6-fd590de7740a", "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": { "text/plain": { "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 ` - + Must be `pub.versia:polls/Vote`. @@ -125,4 +129,13 @@ If a vote is cast to a poll that is closed, the vote should be rejected with a ` ``` - \ No newline at end of file + + +## Interaction Types + + + This section only applies to implementors of the [Interaction Controls Extension](/extensions/interaction-controls). + + +This extension registers the following interaction types: +- `pub.versia:polls#Vote`, for voting on a Poll attached to a Note. \ No newline at end of file diff --git a/app/extensions/reactions/page.mdx b/app/extensions/reactions/page.mdx index 58c885f..eaad613 100644 --- a/app/extensions/reactions/page.mdx +++ b/app/extensions/reactions/page.mdx @@ -15,7 +15,7 @@ User reactions are (like every other entity) federated to all followers, and can - + Must be `pub.versia:reactions/Reaction`. @@ -50,45 +50,28 @@ User reactions are (like every other entity) federated to all followers, and can -## 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: - - - - - URI to a [Collection](/structures/collection) of the [Reactions](#entity-definition) attached to the note. - - - +- `reactions`: A [URI Collection](/structures/collection#uri-collection) of all the reactions to the note. - - - ```jsonc {{ title: "Example Note" }} - { - "id": "01902e09-0f8b-72de-8ee3-9afc0cf5eae1", - "type": "Note", // [!code focus] - "uri": "https://versia.social/notes/01902e09-0f8b-72de-8ee3-9afc0cf5eae1", - "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": [], +```jsonc +{ + "type": "Note", + ... + "collections": { + ... + "pub.versia:reactions/Reactions": "https://example.com/publications/f08a124e-fe90-439e-8be4-15a428a72a19/reactions" } +} +``` - ``` - - - \ No newline at end of file +## Interaction Types + + + This section only applies to implementors of the [Interaction Controls Extension](/extensions/interaction-controls). + + +This extension registers the following interaction types: +- `pub.versia:reactions#React`, for adding a Reaction to a Note. \ No newline at end of file diff --git a/app/extensions/reports/page.mdx b/app/extensions/reports/page.mdx index 48c4de0..a1bf22e 100644 --- a/app/extensions/reports/page.mdx +++ b/app/extensions/reports/page.mdx @@ -13,7 +13,10 @@ When an instance receives a report, it *should* be reviewed by a moderator or ad - + + + This is a [**Transient Entity**](/entities#transient-entities) and does not have a URI. + Must be `pub.versia:reports/Report`. @@ -23,8 +26,8 @@ When an instance receives a report, it *should* be reviewed by a moderator or ad URIs of the content being reported. - - 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. 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", "type": "pub.versia:reports/Report", "author": "https://example.com/users/6f3001a1-641b-4763-a9c4-a089852eec84", - "uri": "https://example.com/reports/f7bbf7fc-88d2-47dd-b241-5d1f770a10f0", "reported": [ "https://test.com/publications/46f936a3-9a1e-4b02-8cde-0902a89769fa", "https://test.com/publications/213d7c56-fb9b-4646-a4d2-7d70aa7d106a" ], - "reason": "spam", + "tags": [ + "spam", + "harassment" + ], "comment": "This is spam." } ``` diff --git a/app/extensions/share/page.mdx b/app/extensions/share/page.mdx index 59c8396..fa38799 100644 --- a/app/extensions/share/page.mdx +++ b/app/extensions/share/page.mdx @@ -17,7 +17,7 @@ When a user shares a note, the note's original author **must** receive the entit - + Must be `pub.versia:share/Share`. @@ -44,4 +44,30 @@ When a user shares a note, the note's original author **must** receive the entit ``` - \ No newline at end of file + + +## 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 + + + This section only applies to implementors of the [Interaction Controls Extension](/extensions/interaction-controls). + + +This extension registers the following interaction types: +- `pub.versia:share#Share`, for sharing a Note. \ No newline at end of file diff --git a/app/extensions/vanity/page.mdx b/app/extensions/vanity/page.mdx index 3b6d3bb..07e48d8 100644 --- a/app/extensions/vanity/page.mdx +++ b/app/extensions/vanity/page.mdx @@ -14,7 +14,7 @@ All properties are optional. - + 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; ``` - + User's birthday. If year is left out or set to `0000`, implementations **SHOULD** not display the year. @@ -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). + + User's timezone. Should be a valid [IANA timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) string. + Versia profiles that should be considered aliases of this profile. @@ -124,6 +127,7 @@ All properties are optional. }, "birthday": "1998-04-12", "location": "+40.6894-074.0447/", + "timezone": "America/New_York", "aliases": [ "https://burger.social/accounts/349ee237-c672-41c1-aadc-677e185f795a", "https://versia.social/users/f565ef02-035d-4974-ba5e-f62a8558331d" diff --git a/app/extensions/websockets/page.mdx b/app/extensions/websockets/page.mdx index aed1969..ab29a30 100644 --- a/app/extensions/websockets/page.mdx +++ b/app/extensions/websockets/page.mdx @@ -22,15 +22,15 @@ Messages sent over the WebSocket connection are JSON objects. - + - Same as the `X-Signature` header in HTTP requests. + Same as the `Versia-Signature` header in HTTP requests. - - Same as the `X-Nonce` header in HTTP requests. + + Same as the `Versia-Signed-At` header in HTTP requests. - Same as the `X-Signed-By` header in HTTP requests. + Same as the `Versia-Signed-By` header in HTTP requests. 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' }} { - "signature": "post /users/1/inbox a2ebc29eb6762a9164fbcffc9271e8a53562a5e725e7187ea7d88d03cbe59341 n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=", - "nonce": "a2ebc29eb6762a9164fbcffc9271e8a53562a5e725e7187ea7d88d03cbe59341", + "signature": "/CjB2L9bcvRg+uP19B4/rqy7Ji9/cqMFPlL3GVCIndnQjYyOpBzJEAl9weDnXm7Jrqa3y6sBC+EYWKThO2r9Bw==", + "signed_at": "1729241807", "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\"}" } diff --git a/app/federation/discovery/page.mdx b/app/federation/discovery/page.mdx index 79a11b8..a1baf1e 100644 --- a/app/federation/discovery/page.mdx +++ b/app/federation/discovery/page.mdx @@ -72,7 +72,7 @@ Accept: application/json }, "compatibility": { "versions": [ - "0.4.0" + "0.5.0" ], "extensions": [ "pub.versia:reactions", diff --git a/app/federation/example/page.mdx b/app/federation/example/page.mdx new file mode 100644 index 0000000..0709a03 --- /dev/null +++ b/app/federation/example/page.mdx @@ -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' }} + + + All examples, domains, names and timestamps are **fictional** and are used **for illustrative purposes only**. + + Some details have been slightly simplified for clarity. + + +## 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" + } + ] +} +``` + + + 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. + + +### 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, @joe@b.social! 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. \ No newline at end of file diff --git a/app/federation/http/page.mdx b/app/federation/http/page.mdx index 19b5f3b..bb421ad 100644 --- a/app/federation/http/page.mdx +++ b/app/federation/http/page.mdx @@ -14,20 +14,20 @@ ALL kinds of HTTP requests/responses between instances **MUST** include a [Signa - + Must include `application/json`. Must include `application/json; charset=utf-8`, if the request has a body. - + See [Signatures](/signatures) for more information. - + See [Signatures](/signatures). - + See [Signatures](/signatures). @@ -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 Accept: application/json User-Agent: CoolServer/1.0 (https://coolserver.com) - X-Signature: post /users/1/inbox a2ebc29eb6762a9164fbcffc9271e8a53562a5e725e7187ea7d88d03cbe59341 n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg= - X-Signed-By: https://example.com/users/1 - X-Nonce: a2ebc29eb6762a9164fbcffc9271e8a53562a5e725e7187ea7d88d03cbe59341 + Versia-Signature: /CjB2L9bcvRg+uP19B4/rqy7Ji9/cqMFPlL3GVCIndnQjYyOpBzJEAl9weDnXm7Jrqa3y6sBC+EYWKThO2r9Bw== + Versia-Signed-By: https://example.com/users/1 + Versia-Signed-At: 1729241687 ``` +## 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. + + + This IETF draft is, well, a draft. However, there are no standards for rate limiting in HTTP, so this is the best we have. + + ## Responses - + Must include `application/json; charset=utf-8`. - + See [Signatures](/signatures) for more information. - + See [Signatures](/signatures). - + See [Signatures](/signatures). @@ -70,9 +80,9 @@ ALL kinds of HTTP requests/responses between instances **MUST** include a [Signa ```http {{ 'title': 'Example Response' }} HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 - X-Signature: get /users/1/followers 8f872d4609d26819d03a7d60ce3db68f5b0dd5a80d5930260294f237e670ab76 YDA64iuZiGG847KPM+7BvnWKITyGyTwHbb6fVYwRx1I - X-Signed-By: https://example.com/users/1 - X-Nonce: 8f872d4609d26819d03a7d60ce3db68f5b0dd5a80d5930260294f237e670ab76 + Versia-Signature: /CjB2L9bcvRg+uP19B4/rqy7Ji9/cqMFPlL3GVCIndnQjYyOpBzJEAl9weDnXm7Jrqa3y6sBC+EYWKThO2r9Bw==+7BvnWKITyGyTwHbb6fVYwRx1I + Versia-Signed-By: https://example.com/users/1 + Versia-Signed-At: 1729241717 ``` \ No newline at end of file diff --git a/app/federation/page.mdx b/app/federation/page.mdx index 6378dc7..9364a26 100644 --- a/app/federation/page.mdx +++ b/app/federation/page.mdx @@ -39,6 +39,6 @@ Shared inboxes are defined in the [Instance Metadata](/entities/instance-metadat ## 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. \ No newline at end of file diff --git a/app/federation/validation/page.mdx b/app/federation/validation/page.mdx index a980085..493b04b 100644 --- a/app/federation/validation/page.mdx +++ b/app/federation/validation/page.mdx @@ -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: - 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 a **valid signature**. - The **length** of all fields (for example, the `username` field on a `User` entity) should be at least 1 character long. diff --git a/app/introduction/page.mdx b/app/introduction/page.mdx index dc51f80..6274e6b 100644 --- a/app/introduction/page.mdx +++ b/app/introduction/page.mdx @@ -35,6 +35,7 @@ The words **MUST**, **MUST NOT**, **SHOULD**, **SHOULD NOT**, and **MAY** are us 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. + - [**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. - **Instance**: An application deploying an **Implementation**. - Using the same nomenclature, an ActivityPub Implementation would be `Mastodon`, and an Instance would be `mastodon.social`. diff --git a/app/links/page.mdx b/app/links/page.mdx new file mode 100644 index 0000000..2d76b78 --- /dev/null +++ b/app/links/page.mdx @@ -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 + + + + + + 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. + + + 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. + + + Instance hosting the referenced content, including the port if it is not the default (i.e. `443` for HTTPS). + + + + + + ``` {{ 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 + ``` + + + +## 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. diff --git a/app/page.tsx b/app/page.tsx index c26510b..bd63a56 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,3 +1,4 @@ +import { Guide } from "@/components/Guides"; import { Resource, type ResourceType } from "@/components/Resources"; import { TeamMember } from "@/components/Team"; import { wrapper } from "@/components/mdx"; @@ -48,9 +49,9 @@ const Page: FC = () => { icon: "tabler:database", }, { - name: "In-depth security docs", + name: "Tested in production :)", 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", }, { @@ -98,7 +99,52 @@ const Page: FC = () => { ))} -

Team

+

Try it out

+ +

+ Use the reference implementation,{" "} + Versia Server! It's a microblogging server + with a focus on feeling like current Fediverse platforms, + like Sharkey and Mastodon. +

+ + + +

Try a Versia instance

+ +

+ If you want to try out Versia without setting up your own + instance, you can use one of the following public instances: +

+ + +

People

+ +

+ You can ask Jesse for help with anything + Versia-related, or if you just want to chat! +

{ { name: "Versia", icon: "bx:server", - url: "https://social.lysand.org/@jessew", + url: "https://beta.versia.social/@jessew", }, { name: "Matrix", @@ -135,7 +181,7 @@ const Page: FC = () => { { name: "Signal", icon: "simple-icons:signal", - url: "https://signal.me/#eu/mdX6iV0ayndNmJst43sNtlw3eFXgHSm7if4Y/mwYT1+qFDzl1PFAeroW+RpHGaRu", + url: "https://signal.me/#eu/Qw6gQXvEfcNrgEFgl-KjOBFiF6-3gWSSghgcpSj9dSedVFIPny5NYazioN5t7E24", }, { name: "Email", @@ -163,7 +209,7 @@ const Page: FC = () => { { name: "Versia", icon: "bx:server", - url: "https://social.lysand.org/@aprl", + url: "https://beta.versia.social/@aprl", }, { name: "Matrix", diff --git a/app/philosophy/principles/page.mdx b/app/philosophy/principles/page.mdx new file mode 100644 index 0000000..0842424 --- /dev/null +++ b/app/philosophy/principles/page.mdx @@ -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. \ No newline at end of file diff --git a/app/signatures/page.mdx b/app/signatures/page.mdx index dacf888..b1359f8 100644 --- a/app/signatures/page.mdx +++ b/app/signatures/page.mdx @@ -17,21 +17,15 @@ Versia uses cryptographic signatures to ensure the integrity and authenticity of ## Signature Definition 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. -- **`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). -- **`X-Nonce`**: A random string generated by the client. +- **`Versia-Signature`**: The signature itself, encoded in base64. +- **`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). +- **`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: - **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`. - - Versia's security model makes replay attacks useless, so they are not a concern. - - For more information, please read [the security model documentation](/security). - - -If a signature fails, is missing or is invalid, the instance **MUST** return a `401 Unauthorized` HTTP status code. +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. ### Calculating the Signature @@ -43,21 +37,22 @@ $0 $1 $2 $3 Where: - `$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). -- `$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) Sign this string using the user's private key. The resulting signature should be encoded in base64. Example: ``` -post /notes a2ebc29eb6762a9164fbcffc9271e8a53562a5e725e7187ea7d88d03cbe59341 n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg= +post /notes 1729243417 n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg= ``` ### Verifying the Signature To verify a signature, the instance must: - 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. - Perform a signature verification using the user's public key. @@ -94,14 +89,14 @@ const privateKey = await crypto.subtle.importKey( ["sign"], ); -const nonce = crypto.getRandomValues(new Uint8Array(32)) +const timestamp = Date.now(); const digest = await crypto.subtle.digest( "SHA-256", new TextEncoder().encode(content) ); 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( "Ed25519", @@ -117,9 +112,9 @@ To send the request, Bob would use the following code: ```typescript const headers = new Headers(); -headers.set("X-Signed-By", "https://bob.com/users/bf44e6ad-7c0a-4560-9938-cf3fd4066511"); -headers.set("X-Nonce", Buffer.from(nonce).toString("hex")); -headers.set("X-Signature", base64Signature); +headers.set("Versia-Signed-By", "https://bob.com/users/bf44e6ad-7c0a-4560-9938-cf3fd4066511"); +headers.set("Versia-Signed-At", timestamp); +headers.set("Versia-Signature", base64Signature); headers.set("Content-Type", "application/json"); 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 const method = request.method.toLowerCase(); const path = new URL(request.url).pathname; -const signature = request.headers.get("X-Signature"); -const nonce = request.headers.get("X-Nonce"); +const signature = request.headers.get("Versia-Signature"); +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( "SHA-256", @@ -143,7 +143,7 @@ const digest = await crypto.subtle.digest( ); const stringToVerify = - `${method} ${path} ${nonce} ${Buffer.from(digest).toString("base64")}`; + `${method} ${path} ${timestamp} ${Buffer.from(digest).toString("base64")}`; const isVerified = await crypto.subtle.verify( "Ed25519", @@ -156,3 +156,27 @@ if (!isVerified) { 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. + + + This is **not** the same as the key's raw bytes. + + This is also not related to the commonly used "PEM" format. + + +```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"); +``` \ No newline at end of file diff --git a/app/structures/collection/page.mdx b/app/structures/collection/page.mdx index 12ee97f..841b87a 100644 --- a/app/structures/collection/page.mdx +++ b/app/structures/collection/page.mdx @@ -17,7 +17,7 @@ Pages should be limited to a reasonable number of entities, such as 20 or 80. - + 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). @@ -65,6 +65,10 @@ Pages should be limited to a reasonable number of entities, such as 20 or 80. "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!" @@ -75,5 +79,62 @@ Pages should be limited to a reasonable number of entities, such as 20 or 80. } ``` + + + +## 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' }} + + + + + + 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). + + + URI to the first page of the collection. Query parameters are allowed. + + + 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`. + + + Total number of entities in the collection, across all pages. + + + URI to the next page of the collection. Query parameters are allowed. + + If there is no next page, this should be `null`. + + + URI to the previous page of the collection. Query parameters are allowed. + + If there is no previous page, this should be `null`. + + + Collection contents. Must be an array of URIs. + + + + + + + ```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" + ] + } + ``` + \ No newline at end of file diff --git a/app/structures/content-format/page.mdx b/app/structures/content-format/page.mdx index 21226d5..b294f93 100644 --- a/app/structures/content-format/page.mdx +++ b/app/structures/content-format/page.mdx @@ -71,7 +71,7 @@ It is a good idea to provide at least two versions of an image (if possible): on - + 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. diff --git a/app/types/page.mdx b/app/types/page.mdx index bdf4524..d35e6fa 100644 --- a/app/types/page.mdx +++ b/app/types/page.mdx @@ -1,23 +1,7 @@ -## ISO8601 +## RFC3339 -```typescript -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}`; -``` +[https://datatracker.ietf.org/doc/html/rfc3339](https://datatracker.ietf.org/doc/html/rfc3339) ## UUID diff --git a/biome.json b/biome.json index b118871..074db76 100644 --- a/biome.json +++ b/biome.json @@ -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": { "enabled": true }, @@ -87,6 +87,6 @@ "globals": ["Bun"] }, "files": { - "ignore": ["node_modules", ".next", ".output", "out"] + "ignore": ["node_modules", ".next", ".output", "out", "public/pl.js"] } } diff --git a/bun.lockb b/bun.lockb index 2ecb9e2..9016e2d 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/components/Code.tsx b/components/Code.tsx index b9bc831..d50d33c 100644 --- a/components/Code.tsx +++ b/components/Code.tsx @@ -144,7 +144,8 @@ function CodePanel({ label?: string; code?: string; }) { - const child = Children.only(children); + // biome-ignore lint/suspicious/noExplicitAny: + const child = Children.only(children) as ReactNode & { props: any }; if (isValidElement(child)) { tag = child.props.tag ?? tag; @@ -205,7 +206,10 @@ function CodeGroupHeader({ )} > {getPanelTitle( - isValidElement(child) ? child.props : {}, + isValidElement(child) + ? // biome-ignore lint/suspicious/noExplicitAny: + (child.props as any) + : {}, )} ))} @@ -238,7 +242,7 @@ function CodeGroupPanels({ function usePreventLayoutShift() { const positionRef = useRef(null); - const rafRef = useRef(); + const rafRef = useRef(undefined); useEffect(() => { return () => { @@ -322,7 +326,8 @@ export function CodeGroup({ }: ComponentPropsWithoutRef & { title: string }) { const languages = Children.map(children, (child) => - getPanelTitle(isValidElement(child) ? child.props : {}), + // biome-ignore lint/suspicious/noExplicitAny: + getPanelTitle(isValidElement(child) ? (child.props as any) : {}), ) ?? []; const tabGroupProps = useTabGroupProps(languages); const hasTabs = Children.count(children) > 1; diff --git a/components/Header.tsx b/components/Header.tsx index 455e1e2..d05d77b 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -51,6 +51,7 @@ export const Header = forwardRef, { className?: string }>( ref={ref} className={clsx( 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", !isInsideMobileNavigation && "backdrop-blur-sm lg:left-72 xl:left-80 dark:backdrop-blur", @@ -94,7 +95,7 @@ export const Header = forwardRef, { className?: string }>(
- +
diff --git a/components/Heading.tsx b/components/Heading.tsx index e9f31b2..c2c7082 100644 --- a/components/Heading.tsx +++ b/components/Heading.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { type ComponentPropsWithoutRef, type ReactNode, + type RefObject, useEffect, useRef, } from "react"; @@ -13,7 +14,7 @@ import { remToPx } from "../lib/remToPx"; import { useSectionStore } from "./SectionProvider"; import { Tag } from "./Tag"; -function AnchorIcon(props: ComponentPropsWithoutRef<"svg">) { +export function AnchorIcon(props: ComponentPropsWithoutRef<"svg">) { return ( ({ }) { level = level ?? (2 as Level); const Component = `h${level}` as "h2" | "h3"; - const ref = useRef(null); + const ref = useRef(null); const registerHeading = useSectionStore((s) => s.registerHeading); const inView = useInView(ref, { @@ -96,10 +97,10 @@ export function Heading({ }); useEffect(() => { - if (level === 2) { + if (level === 2 && ref.current) { registerHeading({ id: props.id, - ref, + ref: ref as RefObject, offsetRem: tag || label ? 8 : 6, }); } diff --git a/components/Logo.tsx b/components/Logo.tsx index ad9cfa0..13dd43d 100644 --- a/components/Logo.tsx +++ b/components/Logo.tsx @@ -1,5 +1,7 @@ import clsx from "clsx"; import type { ComponentPropsWithoutRef } from "react"; +// Uncomment this on dev branches +// import { Badge } from "./Metadata"; export function Logo(props: ComponentPropsWithoutRef<"div">) { return ( @@ -16,7 +18,7 @@ export function Logo(props: ComponentPropsWithoutRef<"div">) { className="h-full rounded-sm" /> - Versia Protocol + Versia Protocol {/* Dev */} ); diff --git a/components/Navigation.tsx b/components/Navigation.tsx index abb34ae..06c3c10 100644 --- a/components/Navigation.tsx +++ b/components/Navigation.tsx @@ -209,6 +209,7 @@ function NavigationGroup({ {link.href === pathname && sections.length > 0 && ( role="list" initial={{ opacity: 0 }} animate={{ @@ -252,9 +253,14 @@ export const navigation: NavGroup[] = [ { title: "Signatures", href: "/signatures" }, { title: "Security", href: "/security" }, { title: "Federation", href: "/federation" }, + { title: "Links", href: "/links" }, { title: "Extensions", href: "/extensions" }, ], }, + { + title: "Philosophy", + links: [{ title: "Principles", href: "/philosophy/principles" }], + }, { title: "Federation", links: [ @@ -262,6 +268,7 @@ export const navigation: NavGroup[] = [ { title: "Validation", href: "/federation/validation" }, { title: "Discovery", href: "/federation/discovery" }, { title: "Delegation", href: "/federation/delegation" }, + { title: "Example", href: "/federation/example" }, ], }, { @@ -278,7 +285,6 @@ export const navigation: NavGroup[] = [ { title: "Follow", href: "/entities/follow" }, { title: "FollowAccept", href: "/entities/follow-accept" }, { title: "FollowReject", href: "/entities/follow-reject" }, - { title: "Group", href: "/entities/group" }, { title: "Notes", href: "/entities/note" }, { title: "InstanceMetadata", href: "/entities/instance-metadata" }, { title: "Unfollow", href: "/entities/unfollow" }, @@ -289,10 +295,15 @@ export const navigation: NavGroup[] = [ title: "Extensions", links: [ { title: "Custom Emojis", href: "/extensions/custom-emojis" }, + { title: "Groups", href: "/extensions/groups" }, { title: "Instance Messaging", href: "/extensions/instance-messaging", }, + { + title: "Interaction Controls", + href: "/extensions/interaction-controls", + }, { title: "Likes", href: "/extensions/likes" }, { title: "Migration", href: "/extensions/migration" }, { title: "Polls", href: "/extensions/polls" }, @@ -323,7 +334,7 @@ export function Navigation(props: ComponentPropsWithoutRef<"nav">) { variant="filled" className="w-full" > - Working Draft 4 + Working Draft 5 diff --git a/components/Property.tsx b/components/Property.tsx new file mode 100644 index 0000000..4b58da3 --- /dev/null +++ b/components/Property.tsx @@ -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 ( +
+
    + + {children} + +
+
+ ); +} + +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 ( +
  • + +
    + +
    + +
    +
    Name
    +
    + {name} +
    + {required && ( + <> +
    Required
    +
    + Required +
    + + )} + {numberType && ( + <> +
    Type
    +
    + {numberType} +
    + + )} + {type && ( + <> +
    Type
    +
    + {typeLink ? ( + {type} + ) : ( + type + )} +
    + + )} +
    Description
    +
    + {children} +
    +
    +
  • + ); +} diff --git a/components/Search.tsx b/components/Search.tsx index 90ef092..d2db96d 100644 --- a/components/Search.tsx +++ b/components/Search.tsx @@ -164,6 +164,7 @@ function LoadingIcon(props: ComponentPropsWithoutRef<"svg">) { function HighlightQuery({ text, query }: { text: string; query: string }) { return ( + // @ts-expect-error types not properly updated to react 19 export { Button } from "./Button"; export { CodeGroup, Code as code, Pre as pre } from "./Code"; +export { Property, Properties } from "./Property"; export function wrapper({ children }: { children: ReactNode }) { return ( @@ -80,81 +81,3 @@ export function Col({ ); } - -export function Properties({ children }: { children: ReactNode }) { - return ( -
    -
      - {children} -
    -
    - ); -} - -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 ( -
  • -
    -
    Name
    -
    - {name} -
    - {required && ( - <> -
    Required
    -
    - Required -
    - - )} - {numberType && ( - <> -
    Type
    -
    - {numberType} -
    - - )} - {type && ( - <> -
    Type
    -
    - {typeLink ? ( - {type} - ) : ( - type - )} -
    - - )} -
    Description
    -
    - {children} -
    -
    -
  • - ); -} diff --git a/mdx/rehype.mjs b/mdx/rehype.mjs index bba9407..e736adf 100644 --- a/mdx/rehype.mjs +++ b/mdx/rehype.mjs @@ -31,6 +31,7 @@ const highlighter = await createHighlighter({ "html", "json5", "jsonc", + "markdown", "bash", "php", "python", diff --git a/package.json b/package.json index 51dcf31..d5e649d 100644 --- a/package.json +++ b/package.json @@ -11,49 +11,53 @@ "uuid": "bun --print 'crypto.randomUUID()'" }, "dependencies": { - "@algolia/autocomplete-core": "^1.17.4", - "@headlessui/react": "^2.1.8", + "@algolia/autocomplete-core": "^1.17.9", + "@headlessui/react": "^2.2.0", "@headlessui/tailwindcss": "^0.2.1", - "@mdx-js/loader": "^3.0.1", - "@mdx-js/react": "^3.0.1", - "@next/mdx": "^14.2.13", + "@mdx-js/loader": "^3.1.0", + "@mdx-js/react": "^3.1.0", + "@next/mdx": "^15.1.5", "@sindresorhus/slugify": "^2.2.1", - "@tailwindcss/typography": "^0.5.15", + "@tailwindcss/typography": "^0.5.16", "@types/mdx": "^2.0.13", - "@types/node": "^22.7.4", - "@types/react": "^18.3.10", - "@types/react-dom": "^18.3.0", + "@types/node": "^22.10.7", + "@types/react": "^19.0.7", + "@types/react-dom": "^19.0.3", "@types/react-highlight-words": "^0.20.0", - "acorn": "^8.12.1", + "acorn": "^8.14.0", "autoprefixer": "^10.4.20", "clsx": "^2.1.1", - "fast-glob": "^3.3.2", + "fast-glob": "^3.3.3", "flexsearch": "^0.7.43", - "framer-motion": "^11.9.0", + "framer-motion": "^11.18.1", "mdast-util-to-string": "^4.0.0", "mdx-annotations": "^0.1.4", - "next": "^14.2.13", - "next-themes": "^0.3.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-highlight-words": "^0.20.0", + "next": "^15.1.5", + "next-themes": "^0.4.4", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-highlight-words": "^0.21.0", "remark": "^15.0.1", "remark-gfm": "^4.0.0", - "remark-mdx": "^3.0.1", - "shiki": "^1.20.0", + "remark-mdx": "^3.1.0", + "shiki": "^1.27.2", "simple-functional-loader": "^1.2.1", - "tailwindcss": "^3.4.13", - "typescript": "^5.6.2", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.3", "unist-util-filter": "^5.0.1", "unist-util-visit": "^5.0.0", - "zustand": "^5.0.0-rc.2" + "zustand": "^5.0.3" }, "devDependencies": { - "@biomejs/biome": "^1.9.2", - "@iconify-icon/react": "^2.1.0", - "@next/bundle-analyzer": "^14.2.13", - "@shikijs/transformers": "^1.20.0", + "@biomejs/biome": "^1.9.4", + "@iconify-icon/react": "^2.3.0", + "@next/bundle-analyzer": "^15.1.5", + "@shikijs/transformers": "^1.27.2", "sharp": "^0.33.5" }, + "overrides": { + "react": "^19.0.0-rc-91061073-20241121", + "react-dom": "^19.0.0-rc-91061073-20241121" + }, "trustedDependencies": ["@biomejs/biome", "sharp"] } diff --git a/tailwind.config.ts b/tailwind.config.ts index 922e84d..2668f4b 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -55,6 +55,10 @@ export default { animation: { 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: { roll: { "0%": { transform: "rotate(0deg)" },