mirror of
https://github.com/versia-pub/docs.git
synced 2025-12-06 14:28:20 +01:00
Merge branch 'main' into feat/lysand3
This commit is contained in:
commit
9e0969f332
26
Dockerfile
26
Dockerfile
|
|
@ -1,11 +1,24 @@
|
|||
FROM oven/bun:alpine
|
||||
FROM oven/bun:alpine as base
|
||||
|
||||
# Install dependencies into temp directory
|
||||
# This will cache them and speed up future builds
|
||||
FROM base AS install
|
||||
RUN mkdir -p /temp/dev
|
||||
COPY package.json bun.lockb /temp/dev/
|
||||
RUN cd /temp/dev && bun install --frozen-lockfile
|
||||
|
||||
# Install with --production (exclude devDependencies)
|
||||
RUN mkdir -p /temp/prod
|
||||
COPY package.json bun.lockb /temp/prod/
|
||||
RUN cd /temp/prod && bun install --frozen-lockfile --production
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
COPY . /app
|
||||
RUN cd /app && bun install
|
||||
RUN cd /app && bun docs:build
|
||||
|
||||
RUN cd ./app && bun install
|
||||
RUN cd ./app && bun docs:build
|
||||
|
||||
FROM oven/bun:alpine
|
||||
FROM base AS final
|
||||
|
||||
COPY --from=builder /app/.vitepress/dist/ /app
|
||||
|
||||
|
|
@ -15,3 +28,6 @@ LABEL org.opencontainers.image.vendor "Lysand.org"
|
|||
LABEL org.opencontainers.image.licenses "MIT"
|
||||
LABEL org.opencontainers.image.title "Lysand Docs"
|
||||
LABEL org.opencontainers.image.description "Documentation for Lysand"
|
||||
|
||||
WORKDIR /app
|
||||
CMD ["bun", "docs:serve"]
|
||||
|
|
@ -28,7 +28,7 @@ The signature is calculated as follows:
|
|||
```
|
||||
(request-target): post /users/uuid/inbox
|
||||
host: example.com
|
||||
date: Fri, 01 Jan 2021 00:00:00 GMT
|
||||
date: 2024-04-10T01:27:24.880Z
|
||||
digest: SHA-256=base64_digest
|
||||
```
|
||||
|
||||
|
|
@ -40,48 +40,41 @@ The `digest` field **MUST** be the SHA-256 digest of the request body, base64-en
|
|||
|
||||
The `date` field **MUST** be the date and time that the request was sent, formatted as follows (ISO 8601):
|
||||
```
|
||||
Fri, 01 Jan 2021 00:00:00 GMT
|
||||
2024-04-10T01:27:24.880Z
|
||||
```
|
||||
|
||||
The `host` field **MUST** be the domain of the server that is receiving the request.
|
||||
The `host` field **MUST** be the host of the server that is receiving the request.
|
||||
|
||||
The `request-target` field **MUST** be the request target of the request, formatted as follows:
|
||||
```
|
||||
post /users/uuid/inbox
|
||||
```
|
||||
|
||||
Where `/users/uuid/inbox` is the path of the request.
|
||||
Where `/users/uuid/inbox` is the path of the request (this will depend on implementations).
|
||||
|
||||
Here is an example of signing a request using TypeScript and the WebCrypto API:
|
||||
Let's imagine a user at `example.com` wants to send something to a user at `receiver.com`'s inbox.
|
||||
|
||||
Here is an example of signing a request using TypeScript and the WebCrypto API (replace `status_author_private_key`, `full_lysand_object_as_string` and sample text appropriate):
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Convert a string into an ArrayBuffer
|
||||
* from https://developers.google.com/web/updates/2012/06/How-to-convert-ArrayBuffer-to-and-from-String
|
||||
*/
|
||||
const str2ab = (str: string) => {
|
||||
const buf = new ArrayBuffer(str.length);
|
||||
const bufView = new Uint8Array(buf);
|
||||
for (let i = 0, strLen = str.length; i < strLen; i++) {
|
||||
bufView[i] = str.charCodeAt(i);
|
||||
}
|
||||
return buf;
|
||||
};
|
||||
|
||||
const privateKey = await crypto.subtle.importKey(
|
||||
"pkcs8",
|
||||
str2ab(atob("base64_private_key")),
|
||||
Uint8Array.from(atob(status_author_private_key), (c) =>
|
||||
c.charCodeAt(0),
|
||||
),
|
||||
"Ed25519",
|
||||
false,
|
||||
["sign"]
|
||||
["sign"],
|
||||
);
|
||||
|
||||
const digest = await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
new TextEncoder().encode("request_body")
|
||||
new TextEncoder().encode(full_lysand_object_as_string),
|
||||
);
|
||||
|
||||
const userInbox = new URL("...");
|
||||
const userInbox = new URL(
|
||||
"https://receiver.com/users/22a56612-9909-48ca-84af-548b28db6fd5/inbox"
|
||||
);
|
||||
|
||||
const date = new Date();
|
||||
|
||||
|
|
@ -91,15 +84,15 @@ const signature = await crypto.subtle.sign(
|
|||
new TextEncoder().encode(
|
||||
`(request-target): post ${userInbox.pathname}\n` +
|
||||
`host: ${userInbox.host}\n` +
|
||||
`date: ${date.toUTCString()}\n` +
|
||||
`date: ${date.toISOString()}\n` +
|
||||
`digest: SHA-256=${btoa(
|
||||
String.fromCharCode(...new Uint8Array(digest))
|
||||
)}\n`
|
||||
)
|
||||
String.fromCharCode(...new Uint8Array(digest)),
|
||||
)}\n`,
|
||||
),
|
||||
);
|
||||
|
||||
const signatureBase64 = btoa(
|
||||
String.fromCharCode(...new Uint8Array(signature))
|
||||
String.fromCharCode(...new Uint8Array(signature)),
|
||||
);
|
||||
```
|
||||
|
||||
|
|
@ -108,50 +101,79 @@ const signatureBase64 = btoa(
|
|||
|
||||
The request can then be sent with the `Signature`, `Origin` and `Date` headers as follows:
|
||||
```ts
|
||||
await fetch("https://example.com/users/uuid/inbox", {
|
||||
await fetch("https://receiver.com/users/22a56612-9909-48ca-84af-548b28db6fd5/inbox", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Date: date.toUTCString(),
|
||||
Origin: "https://example.com",
|
||||
Signature: `keyId="${...}",algorithm="ed25519",headers="(request-target) host date digest",signature="${signatureBase64}"`,
|
||||
Date: date.toISOString(),
|
||||
Origin: "example.com",
|
||||
Signature: `keyId="https://example.com/users/caf18716-800d-4c88-843d-4947ab39ca0f",algorithm="ed25519",headers="(request-target) host date digest",signature="${signatureBase64}"`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
// ...
|
||||
})
|
||||
body: full_lysand_object_as_string,
|
||||
});
|
||||
```
|
||||
|
||||
Example of validation on the server side:
|
||||
|
||||
```typescript
|
||||
// request is a Request object containing the previous request
|
||||
// public_key is the user's public key in raw base64 format
|
||||
// req is a Request object
|
||||
const signatureHeader = req.headers.get("Signature");
|
||||
const origin = req.headers.get("Origin");
|
||||
const date = req.headers.get("Date");
|
||||
|
||||
const signatureHeader = request.headers.get("Signature");
|
||||
if (!signatureHeader) {
|
||||
return errorResponse("Missing Signature header", 400);
|
||||
}
|
||||
|
||||
const signature = signatureHeader.split("signature=")[1].replace(/"/g, "");
|
||||
if (!origin) {
|
||||
return errorResponse("Missing Origin header", 400);
|
||||
}
|
||||
|
||||
const origin = request.headers.get("Origin");
|
||||
if (!date) {
|
||||
return errorResponse("Missing Date header", 400);
|
||||
}
|
||||
|
||||
const date = request.headers.get("Date");
|
||||
const signature = signatureHeader
|
||||
.split("signature=")[1]
|
||||
.replace(/"/g, "");
|
||||
|
||||
const digest = await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
new TextEncoder().encode(await request.text())
|
||||
new TextEncoder().encode(JSON.stringify(body)),
|
||||
);
|
||||
|
||||
const expectedSignedString = `(request-target): ${request.method.toLowerCase()} ${request.url}\n` +
|
||||
`host: ${request.url}\n` +
|
||||
const keyId = signatureHeader
|
||||
.split("keyId=")[1]
|
||||
.split(",")[0]
|
||||
.replace(/"/g, "");
|
||||
|
||||
// TODO: Fetch sender using WebFinger if not found
|
||||
const sender = ... // Get sender from your database via its URI (inside the keyId variable)
|
||||
|
||||
const public_key = await crypto.subtle.importKey(
|
||||
"spki",
|
||||
Uint8Array.from(atob(sender.publicKey), (c) => c.charCodeAt(0)),
|
||||
"Ed25519",
|
||||
false,
|
||||
["verify"],
|
||||
);
|
||||
|
||||
const expectedSignedString =
|
||||
`(request-target): ${req.method.toLowerCase()} ${
|
||||
new URL(req.url).pathname
|
||||
}\n` +
|
||||
`host: ${new URL(req.url).host}\n` +
|
||||
`date: ${date}\n` +
|
||||
`digest: SHA-256=${btoa(digest)}`;
|
||||
`digest: SHA-256=${btoa(
|
||||
String.fromCharCode(...new Uint8Array(digest)),
|
||||
)}\n`;
|
||||
|
||||
// Check if signed string is valid
|
||||
const isValid = await crypto.subtle.verify(
|
||||
"Ed25519",
|
||||
publicKey,
|
||||
new TextEncoder().encode(signature),
|
||||
new TextEncoder().encode(expectedSignedString)
|
||||
public_key,
|
||||
Uint8Array.from(atob(signature), (c) => c.charCodeAt(0)),
|
||||
new TextEncoder().encode(expectedSignedString),
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ curl -X POST -H "Content-Type: application/json" -H "Accept: application/json" -
|
|||
}' https://example.com/users/uuid/inbox
|
||||
```
|
||||
|
||||
The server **MUST** respond with a `200 OK` response code if the operation was successful.
|
||||
The server **MUST** respond with a `201 CREATED` response code if the operation was successful.
|
||||
|
||||
## User Outbox
|
||||
|
||||
|
|
|
|||
|
|
@ -16,15 +16,15 @@ The document **MUST** contain the following information, as specified by the Web
|
|||
|
||||
The `template` field **MUST** be the URI of the server's WebFinger endpoint, which is usually `https://example.com/.well-known/webfinger?resource={uri}`.
|
||||
|
||||
The `resource` field **MUST** be the URI of the user that the server is trying to discover (in the format `acct:uuid@example.com`)
|
||||
The `resource` field **MUST** be the URI of the user that the server is trying to discover (in the format `acct:identifier@example.com`)
|
||||
|
||||
Breaking down this URI, we get the following:
|
||||
|
||||
- `acct`: The protocol of the URI. This is always `acct` for Lysand.
|
||||
- `uuid`: The UUID of the user that the server is trying to discover.
|
||||
- `identifier`: Either the UUID or the username of the user that the server is trying to discover.
|
||||
- `example.com`: The domain of the server that the user is on. This is usually the domain of the server. This can also be a subdomain of the server, such as `lysand.example.com`.
|
||||
|
||||
This format is reminiscent of the `acct` format used by ActivityPub, but with a UUID instead of a username. Users will typically not use the `id` of an actor to identify it, but instead its `username`: servers **MUST** only use the `id` to identify actors.
|
||||
This format is reminiscent of the `acct` format used by ActivityPub, but with either a UUID or a username instead of just an username. Users will typically not use the `id` of an actor to identify it, but instead its `username`: servers **MUST** only use the `id` to identify actors.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -39,7 +39,7 @@ The requesting server **MUST** send the following headers with the request:
|
|||
|
||||
The requestinng server **MUST** send the following query parameters with the request:
|
||||
|
||||
- `resource`: The URI of the user that the server is trying to discover (in the format `acct:uuid@example.com` (replace `uuid` with the user's ID)
|
||||
- `resource`: The URI of the user that the server is trying to discover (in the format `acct:identifier@example.com` (replace `identifier` with the user's ID or username)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -47,7 +47,7 @@ The server **MUST** respond with a `200 OK` response code, and a JSON object in
|
|||
|
||||
```json5
|
||||
{
|
||||
"subject": "acct:uuid@example.com",
|
||||
"subject": "acct:identifier@example.com",
|
||||
"links": [
|
||||
{
|
||||
"rel": "self",
|
||||
|
|
|
|||
|
|
@ -55,7 +55,13 @@ interface Entity {
|
|||
created_at: string;
|
||||
uri: string;
|
||||
type: string;
|
||||
};
|
||||
extensions?: {
|
||||
"org.lysand:custom_emojis"?: {
|
||||
emojis: Emoji[];
|
||||
};
|
||||
[key: string]: object | undefined;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
The `Entity` type is the base type for all entities in the Lysand protocol. It includes the `id`, `created_at`, `uri`, and `type` attributes.
|
||||
|
|
|
|||
|
|
@ -41,3 +41,12 @@ This approach helps prevent potential misuse of the protocol to determine if a u
|
|||
| author | String | Yes |
|
||||
|
||||
URI of the [Actor](./actors) who initiated the action.
|
||||
|
||||
## Types
|
||||
|
||||
```typescript
|
||||
interface Action extends Entity {
|
||||
type: "Like" | "Dislike" | "Follow" | "FollowAccept" | "FollowReject" | "Announce" | "Undo";
|
||||
author: string
|
||||
}
|
||||
```
|
||||
|
|
@ -35,9 +35,8 @@ URI of the object being disliked. Must be of type [Note](./note)
|
|||
## Types
|
||||
|
||||
```typescript
|
||||
interface Dislike extends Entity {
|
||||
interface Dislike extends Action {
|
||||
type: "Dislike";
|
||||
author: string;
|
||||
object: string;
|
||||
}
|
||||
```
|
||||
|
|
@ -35,9 +35,8 @@ URI of the [User](./user) who tried to follow the author
|
|||
## Types
|
||||
|
||||
```typescript
|
||||
interface FollowAccept extends Entity {
|
||||
interface FollowAccept extends Action {
|
||||
type: "FollowAccept";
|
||||
author: string;
|
||||
follower: string;
|
||||
}
|
||||
```
|
||||
|
|
@ -35,9 +35,8 @@ URI of the [User](./user) who tried to follow the author.
|
|||
## Types
|
||||
|
||||
```typescript
|
||||
interface FollowReject extends Entity {
|
||||
interface FollowReject extends Action {
|
||||
type: "FollowReject";
|
||||
author: string;
|
||||
follower: string;
|
||||
}
|
||||
```
|
||||
|
|
|
|||
|
|
@ -35,9 +35,8 @@ URI of the [User](./user) who is being follow requested.
|
|||
## Types
|
||||
|
||||
```typescript
|
||||
interface Follow extends Entity {
|
||||
interface Follow extends Action {
|
||||
type: "Follow";
|
||||
author: string;
|
||||
followee: string;
|
||||
}
|
||||
```
|
||||
|
|
|
|||
|
|
@ -35,9 +35,8 @@ URI of the object being liked. Must be of type [Note](./note)
|
|||
## Types
|
||||
|
||||
```typescript
|
||||
interface Like extends Entity {
|
||||
interface Like extends Action {
|
||||
type: "Like";
|
||||
author: string;
|
||||
object: string;
|
||||
}
|
||||
```
|
||||
|
|
|
|||
|
|
@ -212,7 +212,7 @@ Servers **MUST** respect the visibility of the publication and **MUST NOT** show
|
|||
## Types
|
||||
|
||||
```typescript
|
||||
interface Publication {
|
||||
interface Publication extends Entity {
|
||||
type: "Note" | "Patch";
|
||||
author: string;
|
||||
content?: ContentFormat;
|
||||
|
|
@ -223,6 +223,19 @@ interface Publication {
|
|||
subject?: string;
|
||||
is_sensitive?: boolean;
|
||||
visibility: Visibility;
|
||||
extensions?: Entity["extensions"] & {
|
||||
"org.lysand:reactions"?: {
|
||||
reactions: string;
|
||||
};
|
||||
"org.lysand:polls"?: {
|
||||
poll: {
|
||||
options: ContentFormat[];
|
||||
votes: number[];
|
||||
multiple_choice?: boolean;
|
||||
expires_at: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -152,3 +152,23 @@ Clients should display the most modern format that they support, such as WebP, A
|
|||
| supported_extensions | Array of String | Yes, can be empty array (`[]`) |
|
||||
|
||||
List of extension names that the server supports, in namespaced format (`"org.lysand:reactions"`).
|
||||
|
||||
## Types
|
||||
|
||||
```typescript
|
||||
interface ServerMetadata {
|
||||
type: "ServerMetadata";
|
||||
name: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
website?: string;
|
||||
moderators?: string[];
|
||||
admins?: string[];
|
||||
logo?: ContentFormat;
|
||||
banner?: ContentFormat;
|
||||
supported_extensions: string[];
|
||||
extensions?: {
|
||||
[key: string]: object | undefined;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
|
@ -41,3 +41,13 @@ URI of the [Actor](./actors) who initiated the action.
|
|||
| object | String | Yes |
|
||||
|
||||
URI of the object being undone. The object **MUST** be an [Action](./actions) or a [Note](./note). To undo [Patch](./patch) objects, use a subsequent [Patch](./patch) or delete the original [Note](./note).
|
||||
|
||||
## Types
|
||||
|
||||
```typescript
|
||||
interface Undo extends Entity {
|
||||
type: "Undo";
|
||||
author: string;
|
||||
object: string;
|
||||
}
|
||||
```
|
||||
|
|
@ -332,6 +332,9 @@ interface User extends Entity {
|
|||
dislikes: string;
|
||||
inbox: string;
|
||||
outbox: string;
|
||||
extensions?: Entity["extensions"] & {
|
||||
"org.lysand:vanity"?: VanityExtension;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ All JSON objects disseminated during federation **MUST** be handled as follows:
|
|||
|
||||
## Requests and Responses
|
||||
|
||||
All HTTP requests **MUST** be transmitted over HTTPS. Servers **MUST NOT** accept HTTP requests, unless for development purposes (e.g., if a server is operating on localhost or another local network).
|
||||
All Hypertext Transfer Protocol requests MUST be transmitted using the Hypertext Transfer Protocol Secure Extension. Servers MUST NOT accept requests without TLS (HTTPS), except for development purposes (e.g., if a server is operating on localhost or another local network).
|
||||
|
||||
Servers should support HTTP/2 and HTTP/3 for enhanced performance and security. Servers **MUST** support HTTP/1.1 at a minimum.
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,6 @@
|
|||
"docs:preview": "vitepress preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitepress": "1.0.0-rc.45"
|
||||
"vitepress": "^1.1.0"
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue