diff --git a/apps/docs/examples/attachments-browser.ts b/apps/docs/examples/attachments-browser.ts new file mode 100644 index 0000000000..392bb3ac8f --- /dev/null +++ b/apps/docs/examples/attachments-browser.ts @@ -0,0 +1,44 @@ +import { + type Client, + type Group, + type RemoteAttachment, +} from "@xmtp/browser-sdk"; +import { initPureWasm, RemoteAttachmentCodec } from "@xmtp/browser-sdk/pure"; + +export async function sendAttachment( + client: Client, + group: Group, + path: string, +) { + // #region upload + await initPureWasm(); + const attachments = client.attachments; + if (!attachments.offered) { + throw new Error("The backend does not offer attachments"); + } + + const pending = await attachments.create({ + kind: "path", + path, + filename: "photo.jpg", + mimeType: "image/jpeg", + }); + const remote = pending.remoteAttachment; + await group.send(new RemoteAttachmentCodec(), remote); + await pending.upload(); + // #endregion upload + return remote; +} + +export async function downloadAttachment( + client: Client, + remote: RemoteAttachment, +) { + // #region download + const downloaded = await client.attachments.download(remote); + const path = downloaded.path; + const mimeType = downloaded.mimeType; + const filename = downloaded.filename; + // #endregion download + return { path, mimeType, filename }; +} diff --git a/apps/docs/examples/attachments-node.ts b/apps/docs/examples/attachments-node.ts new file mode 100644 index 0000000000..e12c28c7bf --- /dev/null +++ b/apps/docs/examples/attachments-node.ts @@ -0,0 +1,43 @@ +import { + type Client, + type Group, + type RemoteAttachment, + RemoteAttachmentCodec, +} from "@xmtp/node-sdk"; + +export async function sendAttachment( + client: Client, + group: Group, + path: string, +) { + // #region upload + const attachments = client.attachments; + if (!attachments.offered) { + throw new Error("The backend does not offer attachments"); + } + + const pending = await attachments.create({ + kind: "path", + path, + filename: "photo.jpg", + mimeType: "image/jpeg", + }); + const remote = pending.remoteAttachment; + await group.send(new RemoteAttachmentCodec(), remote); + await pending.upload(); + // #endregion upload + return remote; +} + +export async function downloadAttachment( + client: Client, + remote: RemoteAttachment, +) { + // #region download + const downloaded = await client.attachments.download(remote); + const path = downloaded.path; + const mimeType = downloaded.mimeType; + const filename = downloaded.filename; + // #endregion download + return { path, mimeType, filename }; +} diff --git a/apps/docs/examples/metadata-fields-browser.ts b/apps/docs/examples/metadata-fields-browser.ts new file mode 100644 index 0000000000..958b23ca16 --- /dev/null +++ b/apps/docs/examples/metadata-fields-browser.ts @@ -0,0 +1,29 @@ +import type { Group } from "@xmtp/browser-sdk"; + +export async function updateFields(group: Group) { + // #region update + const category = await group.metadataField("GROUP_CATEGORY"); + const displayName = await group.metadataField("USER_DISPLAY_NAME"); + const pronouns = await group.metadataField("USER_PRONOUNS"); + if (!category || !displayName || !pronouns) { + throw new Error("The group has not registered the required fields"); + } + + await group.updateMetadataField(category.field, { + kind: "replace", + value: { kind: "string", value: "engineering" }, + }); + await group.updateUserData([ + { field: displayName.field, value: { kind: "string", value: "Alix" } }, + { field: pronouns.field, value: { kind: "string", value: "they/them" } }, + ]); + + await group.sync(); + const profiles = await group.userData( + [displayName.field, pronouns.field], + undefined, + ); + await group.updateUserData([{ field: pronouns.field, value: undefined }]); + // #endregion update + return profiles; +} diff --git a/apps/docs/examples/metadata-fields-node.ts b/apps/docs/examples/metadata-fields-node.ts new file mode 100644 index 0000000000..351cfaf66f --- /dev/null +++ b/apps/docs/examples/metadata-fields-node.ts @@ -0,0 +1,29 @@ +import type { Group } from "@xmtp/node-sdk"; + +export async function updateFields(group: Group) { + // #region update + const category = await group.metadataField("GROUP_CATEGORY"); + const displayName = await group.metadataField("USER_DISPLAY_NAME"); + const pronouns = await group.metadataField("USER_PRONOUNS"); + if (!category || !displayName || !pronouns) { + throw new Error("The group has not registered the required fields"); + } + + await group.updateMetadataField(category.field, { + kind: "replace", + value: { kind: "string", value: "engineering" }, + }); + await group.updateUserData([ + { field: displayName.field, value: { kind: "string", value: "Alix" } }, + { field: pronouns.field, value: { kind: "string", value: "they/them" } }, + ]); + + await group.sync(); + const profiles = await group.userData( + [displayName.field, pronouns.field], + undefined, + ); + await group.updateUserData([{ field: pronouns.field, value: undefined }]); + // #endregion update + return profiles; +} diff --git a/apps/docs/scripts/search-config.mjs b/apps/docs/scripts/search-config.mjs index efc14b823e..1c036d229d 100644 --- a/apps/docs/scripts/search-config.mjs +++ b/apps/docs/scripts/search-config.mjs @@ -9,5 +9,5 @@ export const searchRanking = { termSaturation: 2, termSimilarity: 9, // Keep guide title matches above repeated terms in longer guide bodies. - metaWeights: { title: 0, guideTitle: 100 }, + metaWeights: { title: 0, guideTitle: 1000 }, }; diff --git a/apps/docs/src/content/docs/content-types/attachments.md b/apps/docs/src/content/docs/content-types/attachments.md deleted file mode 100644 index 3562a091da..0000000000 --- a/apps/docs/src/content/docs/content-types/attachments.md +++ /dev/null @@ -1,119 +0,0 @@ ---- -title: Support attachments in your app built with XMTP ---- - -Use the remote attachment, multiple remote attachments, or attachment content type to support attachments in your app. - -- Use the [remote attachment content type](#support-remote-attachments) to send one encrypted file through storage. -- Use the [multiple remote attachments content type](#support-multiple-remote-attachments) to send several encrypted files in one message. -- Use the [attachment content type](#support-small-inline-attachments) to send small files inline. - -## Support remote attachments - -Use a remote attachment to send a file stored outside the message. The backend must offer attachment storage for SDK-managed uploads. An app can also use its own storage through an upload callback. - -To send several remote attachments in one message, see [Support multiple remote attachments](#support-multiple-remote-attachments). - -## How remote attachments work - -XMTP messages have a maximum size limit. Files that exceed this limit can't be sent inline and are instead handled as **remote attachments**. For this, the file is encrypted, uploaded to an external storage provider, and a reference URL is sent in the message. The recipient then downloads and decrypts the file using the metadata from the message. - -The upload and download limits still apply. SDK-managed uploads use the backend's `maxUploadBytes` setting, which defaults to 100 MiB. Downloads use the app's `maxDownloadBytes` limit, or the backend's upload limit when the app sets none. Without an attachment offer, the default download limit is 100 MiB. A download is also bounded by the advertised `contentLength` and the 32-bit length limit. - -For app-owned storage, you need three things: - -1. **A file** to attach (image, document, etc.) -2. **A storage provider** to host the encrypted file at an HTTPS URL that answers GET with status 200 -3. **An upload callback** that tells the SDK how to upload the encrypted bytes and return a download URL - -## Encryption - -The SDK encrypts the attachment before the upload callback runs. The host stores an encrypted `EncodedContent` envelope of type `xmtp.org/attachment:1.0`, which contains the file bytes and metadata. - -| Property | Value | -| ------------------ | --------------------------------------------------------- | -| Cipher | AES-256-GCM | -| Key derivation | HKDF-SHA256 with a random 32-byte secret and 32-byte salt | -| Nonce | Random, 12 bytes | -| Authentication tag | 16 bytes, appended to the ciphertext | -| Integrity | Hex SHA-256 `contentDigest` of the encrypted bytes | - -The upload callback's `attachment.payload` contains encrypted bytes. The download URL and decryption metadata travel in the XMTP message. A storage host that does not have that metadata cannot decrypt the file. - -## Structures - -### Inline attachment - -Type ID: `xmtp.org/attachment:1.0`. Its fallback is `Can't display . This app doesn't support attachments.`. `shouldPush` defaults to `true` on all four platforms. - -| Field | Type | Wire location | Required | -| ---------- | ------ | -------------------- | -------- | -| `filename` | string | `filename` parameter | No | -| `mimeType` | string | `mimeType` parameter | Yes | -| `content` | bytes | Message content | Yes | - -### Remote attachment - -Type ID: `xmtp.org/remoteStaticAttachment:1.0`. Its fallback names the unsupported file. `shouldPush` defaults to `true` on all four platforms. - -| Field | Type | Wire location | Required | -| --------------- | -------------------- | --------------------------------------------------- | -------- | -| `url` | string | Message content, UTF-8 | Yes | -| `contentDigest` | hex SHA-256 | `contentDigest` parameter | Yes | -| `secret` | hex string, 32 bytes | `secret` parameter | Yes | -| `salt` | hex string, 32 bytes | `salt` parameter | Yes | -| `nonce` | hex string, 12 bytes | `nonce` parameter | Yes | -| `scheme` | string | `scheme` parameter | Yes | -| `contentLength` | integer | `contentLength` parameter; encrypted payload length | No | -| `filename` | string | `filename` parameter | No | - -### Multiple remote attachments - -Type ID: `xmtp.org/multiRemoteStaticAttachment:1.0`. The payload is a `MultiRemoteAttachment` protobuf with repeated `RemoteAttachmentInfo` entries. The fallback says that the app does not support multiple remote attachments. `shouldPush` defaults to `true` on all four platforms. - -Each `RemoteAttachmentInfo` contains `url`, `contentDigest`, `secret`, `salt`, `nonce`, `scheme`, optional encrypted `contentLength`, and optional `filename`. Each new encrypted file has separate encryption material. The SDK record uses byte arrays for `secret`, `salt`, and `nonce`; the single-attachment codec writes those arrays as hexadecimal parameters. - -Each attachment in the attachments array contains a URL that points to an encrypted `EncodedContent` object. The content must be accessible by an HTTP `GET` request to the URL. - -## Support multiple remote attachments - -Use `MultiRemoteAttachmentCodec` with an `attachments` array of remote attachment records. Each file has its own download and size checks. The message that contains the array must fit the backend's message envelope limit. - -## Send and receive - -Use `ctx.sendRemoteAttachment(file)` to create, encrypt, upload, and send a file through backend attachment storage. No upload callback is required. - -To use app-owned storage, supply an upload callback. This example uploads encrypted bytes to Pinata: - -```ts source="content-types-attachments-1.ts" region="example1" - -``` - -With the callback in this example: - -1. The SDK encodes the attachment envelope and encrypts it -2. Your `uploadCallback` receives the encrypted payload and uploads it to Pinata's IPFS network -3. Pinata returns a CID, which is converted to a gateway URL -4. The SDK sends a message containing the URL and decryption metadata (salt, nonce, secret, content digest) - -## Receive and decrypt a remote attachment - -When your agent receives an attachment, use `downloadRemoteAttachment` to download and decrypt it in one step: - -```ts source="content-types-attachments-2.ts" region="example2" - -``` - -The `downloadRemoteAttachment` utility uses `client.attachments.download()`. Core checks the URL, size, digest, and authentication tag before it returns the local file. The utility reads that file and returns its filename, MIME type, and bytes. - -Downloads require HTTPS. Loopback HTTP is permitted only with `allowPrivateNetwork` enabled. Native clients check resolved addresses and redirects. Browser downloads require CORS support and reject redirects. See [Remote attachments specification](/specs/atch-remote-attachments/) for the full transfer rules. - -## Support small inline attachments - -Use remote attachments for files near the message limit. Inline files share that limit with the content envelope and MLS framing. - -The backend's default message envelope limit is 1 MiB. Use the deployment's current `maxEnvelopeBytes` limit. A file smaller than 1 MiB can still exceed the limit after encoding and MLS framing. - -To handle unsupported content types, refer to the fallback section. - -See [Fallback and unsupported content](/content-types/overview/#fallback-and-unsupported-content). diff --git a/apps/docs/src/content/docs/content-types/attachments.mdx b/apps/docs/src/content/docs/content-types/attachments.mdx new file mode 100644 index 0000000000..f72cc56302 --- /dev/null +++ b/apps/docs/src/content/docs/content-types/attachments.mdx @@ -0,0 +1,317 @@ +--- +title: Support attachments in your app built with XMTP +--- + +import { Tabs, TabItem } from "@astrojs/starlight/components"; + +Use the remote attachment, multiple remote attachments, or attachment content type to support attachments in your app. + +- Use the [remote attachment content type](#support-remote-attachments) to send one encrypted file through storage. +- Use the [multiple remote attachments content type](#support-multiple-remote-attachments) to send several encrypted files in one message. +- Use the [attachment content type](#support-small-inline-attachments) to send small files inline. + +## Support remote attachments + +Use a remote attachment to send a file stored outside the message. The backend must offer attachment storage for SDK-managed uploads. An app can also use its own storage through an upload callback. + +To send several remote attachments in one message, see [Support multiple remote attachments](#support-multiple-remote-attachments). + +## Native attachment uploads and downloads + +The SDK has built-in file transfers through `client.attachments` on Browser and +Node, and `client.attachments()` on Kotlin and Swift. These calls encrypt, +stage, upload, verify, and decrypt files. An upload callback is not required. +The Agent SDK uses the same transfer service for `ctx.sendRemoteAttachment`. + +For uploads, the operator must configure +[attachment storage](/deploy/attachment-storage/). Check +`client.attachments.offered` on Browser and Node or +`client.attachments().offered()` on Kotlin and Swift before you show the upload +action. Downloads can use any permitted HTTPS host, including +another deployment or app-owned storage. They do not require an attachment +offer from the recipient's backend. + +### Create, send, and upload + +1. Call `create` with a path or bytes, a MIME type, and an optional filename. +2. Get the `RemoteAttachment` from the returned pending attachment. +3. Send that record with `RemoteAttachmentCodec`. +4. Call `pending.upload()` and handle its result. + +Creation reads the source once and stores both a local plaintext file and +staged ciphertext. It returns before any upload request. After creation, the +source can move or be removed without affecting the upload. Prefer a path +source for large native files. Path creation and transfers stream the file; +a byte source already holds the file in application memory. + +The following examples use an existing `client` and `group`. The path must name +a readable file in the application's filesystem. Browser paths name OPFS entries. + +
+ + + +```ts source="attachments-browser.ts" region="upload" + +``` + + + + +```ts source="attachments-node.ts" region="upload" + +``` + + + + +```kotlin +val attachments = client.attachments() +check(attachments.offered()) { "The backend does not offer attachments" } + +val pending = attachments.create( + AttachmentSource.Path( + path = "/path/to/photo.jpg", + filename = "photo.jpg", + mimeType = "image/jpeg", + ), +) +val remote = pending.remoteAttachment() +group.send(RemoteAttachmentCodec(), remote) +pending.upload() +``` + + + + +```swift +let attachments = client.attachments() +if attachments.offered() { + let pending = try await attachments.create(source: .path( + path: "/path/to/photo.jpg", + filename: "photo.jpg", + mimeType: "image/jpeg" + )) + let remote = pending.remoteAttachment() + _ = try await group.send(RemoteAttachmentCodec(), value: remote) + try await pending.upload() +} +``` + + + +
+ +For an in-memory file, use `AttachmentSource.Bytes(bytes, filename, mimeType)` +on Kotlin or `.bytes(bytes:filename:mimeType:)` on Swift. Browser path sources +name OPFS entries. Browser byte sources copy the caller's bytes at the SDK +boundary. + +Sending the record does not start the upload. With the sequence above, a +recipient can receive the message before the file is available. Show a pending +state and allow another download attempt if the first returns `not_found`. +An app can also complete the upload before sending when it needs the object +to be available first. + +### Download to a local file + +Decode the received content with `RemoteAttachmentCodec` to get a +`RemoteAttachment`. Pass that record to `download`: + +
+ + + +```ts source="attachments-browser.ts" region="download" + +``` + + + + +```ts source="attachments-node.ts" region="download" + +``` + + + + +```kotlin +val downloaded = client.attachments().download(remote) +val path = downloaded.path +val mimeType = downloaded.mimeType +val filename = downloaded.filename +``` + + + + +```swift +let downloaded = try await client.attachments().download(remote: remote) +let path = downloaded.path +let mimeType = downloaded.mimeType +let filename = downloaded.filename +``` + + + +
+ +`DownloadedAttachment.path` names a file with the original content bytes, not +the encoded envelope or ciphertext. The returned MIME type and filename come +from the decrypted envelope. Use the local path to open or display the file. +The SDK does not create a preview or thumbnail. + +The SDK checks the ciphertext digest, decrypts and authenticates the envelope, +and checks its attachment type before it puts the plaintext at its final path. +A failed download leaves no partial file there. If a file already exists at +that path, the SDK returns it without a network request. Receiving a message +does not download its attachment. Request downloads when the user opens the +file, or when your app explicitly chooses to fetch it. + +### Status, retry, and local storage + +| Call | Use | +| --------------------- | ----------------------------------------------------------------------------------------- | +| `pending.status()` | Read `waiting`, `uploading`, `complete`, or `failed`. A failed status includes its cause. | +| `listPending()` | Find incomplete uploads after an application restart. | +| `pending(remote)` | Recover the pending handle for a known remote attachment. | +| `pending.upload()` | Start an upload, join a running upload, or return success for a completed upload. | +| `localPath(remote)` | Derive the local path without fetching the object or checking that the file exists. | +| `listLocal()` | List local plaintext files. Each record's path is relative to the attachment directory. | +| `deleteLocal(remote)` | Remove local plaintext, staged ciphertext, and attachment records. | + +Kotlin uses the calls in the table without argument labels. Swift uses +`pending(remote:)`, `localPath(remote:)`, and `deleteLocal(remote:)`. Browser +and Node expose `remoteAttachment` as a property on the pending handle; +Kotlin and Swift use `remoteAttachment()`. + +Pending records survive a restart when the app keeps the same database and +attachment directory. The default maximum pending age is 86,400 seconds. +`AttachmentOptions.maxPendingAgeSeconds` changes it. Expired records and staged +ciphertext are removed at client creation and by the background cleanup worker. +Successful uploads release staged ciphertext and disappear from `listPending`. +The local plaintext remains until the app deletes it. + +The SDK does not retry failed transfers automatically. Handle the typed +attachment error and its retryable detail. A failed status also carries an +`AttachmentFailure` with a cause and, when available, an HTTP status. A network +failure can succeed after a delay. Invalid data, an oversized file, or unusable +staged ciphertext needs a corrected input. A credential failure needs the +credential action reported by the error. See the +[failure causes](/specs/atch-remote-attachments/#7-failures) before you retry. + +Subscribe to client events to show transfer state. Upload and download events +report started, completed, and failed transfers; `attachment.deleted` reports +local deletion. These are state events, not byte progress updates. Each client +reports only the changes it makes. + +Keep the whole client storage directory when you need to resume transfers. +Local deletion does not delete the remote object or its XMTP message. It ends +transfers managed by this client for the deleted attachment. A later download +can fetch the object again while storage still serves it. An unchanged +attachment forwarded in another message shares the same local file. + +## How remote attachments work + +XMTP messages have a maximum size limit. Files that exceed this limit can't be sent inline and are instead handled as **remote attachments**. For this, the file is encrypted, uploaded to an external storage provider, and a reference URL is sent in the message. The recipient then downloads and decrypts the file using the metadata from the message. + +The upload and download limits still apply. SDK-managed uploads use the backend's `maxUploadBytes` setting, which defaults to 100 MiB. Downloads use the app's `maxDownloadBytes` limit, or the backend's upload limit when the app sets none. Without an attachment offer, the default download limit is 100 MiB. A download is also bounded by the advertised `contentLength` and the 32-bit length limit. + +For app-owned storage, you need three things: + +1. **A file** to attach (image, document, etc.) +2. **A storage provider** to host the encrypted file at an HTTPS URL that answers GET with status 200 +3. **An upload callback** that tells the SDK how to upload the encrypted bytes and return a download URL + +## Encryption + +The SDK encrypts the attachment before the upload callback runs. The host stores an encrypted `EncodedContent` envelope of type `xmtp.org/attachment:1.0`, which contains the file bytes and metadata. + +| Property | Value | +| ------------------ | --------------------------------------------------------- | +| Cipher | AES-256-GCM | +| Key derivation | HKDF-SHA256 with a random 32-byte secret and 32-byte salt | +| Nonce | Random, 12 bytes | +| Authentication tag | 16 bytes, appended to the ciphertext | +| Integrity | Hex SHA-256 `contentDigest` of the encrypted bytes | + +The upload callback's `attachment.payload` contains encrypted bytes. The download URL and decryption metadata travel in the XMTP message. A storage host that does not have that metadata cannot decrypt the file. + +## Structures + +### Inline attachment + +Type ID: `xmtp.org/attachment:1.0`. Its fallback is `Can't display . This app doesn't support attachments.`. `shouldPush` defaults to `true` on all four platforms. + +| Field | Type | Wire location | Required | +| ---------- | ------ | -------------------- | -------- | +| `filename` | string | `filename` parameter | No | +| `mimeType` | string | `mimeType` parameter | Yes | +| `content` | bytes | Message content | Yes | + +### Remote attachment + +Type ID: `xmtp.org/remoteStaticAttachment:1.0`. Its fallback names the unsupported file. `shouldPush` defaults to `true` on all four platforms. + +| Field | Type | Wire location | Required | +| --------------- | -------------------- | --------------------------------------------------- | -------- | +| `url` | string | Message content, UTF-8 | Yes | +| `contentDigest` | hex SHA-256 | `contentDigest` parameter | Yes | +| `secret` | hex string, 32 bytes | `secret` parameter | Yes | +| `salt` | hex string, 32 bytes | `salt` parameter | Yes | +| `nonce` | hex string, 12 bytes | `nonce` parameter | Yes | +| `scheme` | string | `scheme` parameter | Yes | +| `contentLength` | integer | `contentLength` parameter; encrypted payload length | No | +| `filename` | string | `filename` parameter | No | + +### Multiple remote attachments + +Type ID: `xmtp.org/multiRemoteStaticAttachment:1.0`. The payload is a `MultiRemoteAttachment` protobuf with repeated `RemoteAttachmentInfo` entries. The fallback says that the app does not support multiple remote attachments. `shouldPush` defaults to `true` on all four platforms. + +Each `RemoteAttachmentInfo` contains `url`, `contentDigest`, `secret`, `salt`, `nonce`, `scheme`, optional encrypted `contentLength`, and optional `filename`. Each new encrypted file has separate encryption material. The SDK record uses byte arrays for `secret`, `salt`, and `nonce`; the single-attachment codec writes those arrays as hexadecimal parameters. + +Each attachment in the attachments array contains a URL that points to an encrypted `EncodedContent` object. The content must be accessible by an HTTP `GET` request to the URL. + +## Support multiple remote attachments + +Use `MultiRemoteAttachmentCodec` with an `attachments` array of remote attachment records. Each file has its own download and size checks. The message that contains the array must fit the backend's message envelope limit. + +## Send and receive with the Agent SDK + +Use `ctx.sendRemoteAttachment(file)` to create, encrypt, upload, and send a file through backend attachment storage. No upload callback is required. + +To use app-owned storage, supply an upload callback. This example uploads encrypted bytes to Pinata: + +```ts source="content-types-attachments-1.ts" region="example1" + +``` + +With the callback in this example: + +1. The SDK encodes the attachment envelope and encrypts it +2. Your `uploadCallback` receives the encrypted payload and uploads it to Pinata's IPFS network +3. Pinata returns a CID, which is converted to a gateway URL +4. The SDK sends a message containing the URL and decryption metadata (salt, nonce, secret, content digest) + +## Receive and decrypt a remote attachment + +When your agent receives an attachment, use `downloadRemoteAttachment` to download and decrypt it in one step: + +```ts source="content-types-attachments-2.ts" region="example2" + +``` + +The `downloadRemoteAttachment` utility uses `client.attachments.download()`. Core checks the URL, size, digest, and authentication tag before it returns the local file. The utility reads that file and returns its filename, MIME type, and bytes. + +Downloads require HTTPS. Loopback HTTP is permitted only with `allowPrivateNetwork` enabled. Native clients check resolved addresses and redirects. Browser downloads require CORS support and reject redirects. See [Remote attachments specification](/specs/atch-remote-attachments/) for the full transfer rules. + +## Support small inline attachments + +Use remote attachments for files near the message limit. Inline files share that limit with the content envelope and MLS framing. + +The backend's default message envelope limit is 1 MiB. Use the deployment's current `maxEnvelopeBytes` limit. A file smaller than 1 MiB can still exceed the limit after encoding and MLS framing. + +To handle unsupported content types, refer to the fallback section. + +See [Fallback and unsupported content](/content-types/overview/#fallback-and-unsupported-content). diff --git a/apps/docs/src/content/docs/deploy/attachment-storage.md b/apps/docs/src/content/docs/deploy/attachment-storage.md index 16c1c334b7..a10d33225c 100644 --- a/apps/docs/src/content/docs/deploy/attachment-storage.md +++ b/apps/docs/src/content/docs/deploy/attachment-storage.md @@ -9,6 +9,20 @@ signs one PUT request for each upload. Clients send the ciphertext to the storage target and fetch it from `base_url`. The backend does not store the ciphertext. +Applications use the SDK's built-in attachment service to create a pending +attachment, send its remote record, and upload the staged ciphertext. The +service requests a signed PUT from the backend and sends the file directly to +the target. The recipient explicitly requests a download; message receipt +does not fetch the file. See +[Native attachment uploads and downloads](/content-types/attachments/#native-attachment-uploads-and-downloads) +for Kotlin and Swift examples, retry behavior, and local file management. + +The published upload limit bounds the encrypted envelope, including its MIME +type, filename, framing, and authentication tag. A source file exactly at the +limit can exceed it after encryption. Target retention controls how long a +remote file remains available. It does not remove the plaintext copies that +clients already downloaded. + ## Configure the target ```toml diff --git a/apps/docs/src/content/docs/get-started/run-the-backend.mdx b/apps/docs/src/content/docs/get-started/run-the-backend.mdx index 97e6b49745..aba9c53885 100644 --- a/apps/docs/src/content/docs/get-started/run-the-backend.mdx +++ b/apps/docs/src/content/docs/get-started/run-the-backend.mdx @@ -298,6 +298,16 @@ left; it denies every other type. ```toml [[application_components]] component_id = 0xC000 +name = "GROUP_CATEGORY" +component_type = "string" +insert_policy = "allow_if_admin" +update_policy = "allow_if_admin" +delete_policy = "allow_if_super_admin" +in_groups = true +in_dms = false + +[[application_components]] +component_id = 0xC001 name = "USER_PRONOUNS" component_type = "tls_map_inbox_id_string" insert_policy = "allow_if_self_or_non_member" @@ -307,10 +317,27 @@ in_groups = true in_dms = true ``` +`GROUP_CATEGORY` is one string for the group. `USER_PRONOUNS` is one string per +inbox in each group or DM. The name prefix does not grant permissions. The map +type and the three policies control how members can change their entries. +These values are encrypted conversation metadata. The backend publishes their +definitions but cannot read their values. + +IDs from `0xC000` through `0xFCFF` are mutable. IDs from `0xFD00` through +`0xFEFF` are immutable: a value can be set once, then cannot be changed or +removed. Registration leaves the value unset in either range. Application +scalars, collection keys, and collection values are limited to 8,192 bytes; +serialized collection snapshots are limited to 65,536 bytes. + +See [Configurable fields](/sdk/groups/#configurable-fields) for field discovery, +typed reads and writes, and user data examples. + Keep a published field's ID, name, type, policies, and `in_groups` and `in_dms` stable. A conversation copies the definition when it registers the ID, so a -change reaches only conversations that register it later; existing ones keep -the old definition. Retire a field by removing it and publish a replacement +change reaches only conversations that register it later; existing registered +fields keep the old definition. A client with registry write authority can +register missing eligible fields in an existing conversation when it makes a +commit. Retire a field by removing it and publish a replacement under a new ID and name. Prefix the name of a field that holds one value per inbox with `USER_`, as the diff --git a/apps/docs/src/content/docs/sdk/groups.mdx b/apps/docs/src/content/docs/sdk/groups.mdx index 929d12fa75..e9bf714f7a 100644 --- a/apps/docs/src/content/docs/sdk/groups.mdx +++ b/apps/docs/src/content/docs/sdk/groups.mdx @@ -2,6 +2,8 @@ title: Groups --- +import { Tabs, TabItem } from "@astrojs/starlight/components"; + Robust group chat permissions are key to providing users with a friendly and safe group chat experience. ## Understand the group permissions system @@ -98,3 +100,156 @@ Group chats can have metadata, like names, descriptions, and images. Metadata ca | App data | `updateAppData(data, expected)` | 8,192 bytes | Read metadata from the asynchronous `group.state()` snapshot on all four SDKs. App data is a string. Pass the previous value as `expected` to update app data only if it still matches, or pass `undefined` on Browser and Node or `null`/`nil` on mobile to omit that check. Serialize structured data before you set it. See [Disappearing messages](/sdk/disappearing-messages/). + +### Configurable fields + +An operator can publish additional fields through +[`[[application_components]]`](/get-started/run-the-backend/#application_components). +These fields can hold a group value, a set, or a map. An inbox-keyed map holds +one value per user. All members who can read the conversation can read these +values. User fields are conversation data, not a global inbox profile. + +New groups and DMs register the fields selected by `in_groups` and `in_dms`. +Registration defines the type and the insert, update, and delete policies. It +does not set a value. Existing conversations can register missing fields when +a client with registry write authority makes a commit. A backend configuration +change does not replace a field already in the conversation's registry. + +Use the conversation's descriptors to build a form or validate a value: + +| Method | Result | +| --------------------------------------- | ---------------------------------------------------------------------------- | +| `metadataFields()` | Registered field descriptors, with refs, types, policies, and `isUserField`. | +| `metadataField(name)` | The descriptor with this name, or no result. | +| `metadataValue(field)` | A scalar, map, or set value, or no value if unset. | +| `metadataValues(fields)` | Field and value records in the order requested. | +| `mapValue(field, key)` | One map entry, or no value if the key is absent. | +| `updateMetadataField(field, operation)` | A typed change checked against the committed field policies. | + +A `MetadataFieldRef` identifies a field by `componentId`. Its name is a label +from the well-known fields or the client's backend configuration. A field can +have no name when the local configuration does not name its ID. Keep the ref +from the descriptor. Do not use a backend definition to assume the type or +policy of an existing field. + +Use `ComponentMutation.Replace` on Kotlin or `.replace` on Swift for a scalar. +Use `MapDelta` or `SetDelta` on Kotlin, or `.mapDelta` or `.setDelta` on Swift, +for collection changes. Map operations insert, update, or delete one key; set +operations insert or delete one key. A field in the immutable application +range can be set only once. Wrong types, unknown fields, unsupported types, and +denied writes produce typed errors. + +Browser and Node use records with `kind: "replace"`, `kind: "mapDelta"`, or +`kind: "setDelta"`. A scalar value is a `FieldValue` with `kind: "string"` or +`kind: "bytes"` and a matching `value`. + +Application scalars, collection keys, and collection values have an 8,192-byte +limit. A serialized collection has a 65,536-byte limit. These limits also apply +to `USER_DISPLAY_NAME` and `GROUP_IMAGE`. The existing string fields retain the +limits in the table above. + +### Read and update user fields + +`USER_DISPLAY_NAME` is a well-known string map keyed by inbox ID. It is +registered in new groups and DMs. Members can set or remove their own value. +The default policy also lets members remove an entry for an inbox that has +left. An application field such as `USER_PRONOUNS` can use the same shape and +policies. The `USER_` prefix is a naming convention. The inbox-keyed map type +determines whether `isUserField` is true. + +`updateUserData(values)` uses the caller's inbox ID. Each value sets that +inbox's entry; an `undefined` value on Browser and Node, `null` on Kotlin, +or `nil` on Swift removes it. All changes +in the call use one commit. Duplicate fields, non-user fields, and type +mismatches fail the call. Clearing an absent entry makes no change. + +The following examples run with an existing `group`. The operator must publish +`GROUP_CATEGORY` and `USER_PRONOUNS` as shown in the backend guide. The group +must have registered them, and the caller must be an admin to set the category. + +
+ + + +```ts source="metadata-fields-browser.ts" region="update" + +``` + + + + +```ts source="metadata-fields-node.ts" region="update" + +``` + + + + +```kotlin +val category = checkNotNull(group.metadataField("GROUP_CATEGORY")).field +group.updateMetadataField( + category, ComponentMutation.Replace(FieldValue.String("engineering")), +) + +val displayName = metadataFieldRef(WellKnownMetadataField.USER_DISPLAY_NAME) +val pronouns = checkNotNull(group.metadataField("USER_PRONOUNS")).field + +group.updateUserData( + listOf( + UserFieldUpdate(displayName, FieldValue.String("Alix")), + UserFieldUpdate(pronouns, FieldValue.String("they/them")), + ), +) + +group.sync() +val profiles = group.userData(listOf(displayName, pronouns), null) +group.updateUserData(listOf(UserFieldUpdate(pronouns, null))) +``` + + + + +```swift +if let category = try await group.metadataField(name: "GROUP_CATEGORY")?.field { + try await group.updateMetadataField( + field: category, operation: .replace(.string("engineering")) + ) +} + +let displayName = metadataFieldRef(field: .userDisplayName) +if let pronouns = try await group.metadataField(name: "USER_PRONOUNS")?.field { + try await group.updateUserData(values: [ + UserFieldUpdate(field: displayName, value: .string("Alix")), + UserFieldUpdate(field: pronouns, value: .string("they/them")), + ]) + + try await group.sync() + let profiles = try await group.userData( + fields: [displayName, pronouns], inboxIds: nil + ) + try await group.updateUserData(values: [ + UserFieldUpdate(field: pronouns, value: nil), + ]) +} +``` + + + +
+ +`userData(fields, inboxIds)` returns a map from inbox ID to present field and +value records. Omit the filters with `undefined` on Browser and Node, +`null` on Kotlin, or `nil` on Swift to read all user fields for current members +or the two DM participants. An empty filter selects nothing. An inbox with no +selected values has an empty list. Supply inbox IDs to read entries for former +members. Sync before reads when you need changes from other members. + +### Store a group image + +`GROUP_IMAGE` holds opaque bytes. It is separate from the `GROUP_IMAGE_URL` +string and is registered in new groups. An app can upload an image with the +[attachment API](/content-types/attachments/#native-attachment-uploads-and-downloads), +serialize the returned `RemoteAttachment`, and store those bytes with +`updateMetadataField`. Agree on the serialization format with the other apps +that read the group. The SDK does not parse or fetch these bytes. A reader +decodes the descriptor and explicitly requests the attachment download.