Media and HTTP
Softchat validates the bounded imeta union and performs compatibility or
streaming attachment transforms. The host owns file descriptors, private
staging, HTTP execution, redirects, background work, and final publication.
Attachment metadata
media.metadataParse and preserve bounded Softchat imeta metadata.
- Inputs and exact bounds
- at most 128 fields
- at most 16 fallback URLs
- Output
- Validated metadata retaining unknown fields.
- Trust and authentication
- Metadata does not authenticate downloaded bytes.
- Stable errors
invalid_attachment_metadata· handling and retry rules
Ownership, lifecycle, and execution rules
- Ownership
- Rust owns protocol semantics, Android account SQLite/use-case state, and optional native relay I/O; the host owns account lifecycle, HTTP/media/background effects, files, and UI.
- Retry
- Retry only host transport or explicitly retryable session work; validation and authentication failures are permanent for the same input.
- Lifecycle
- Language ownership is automatic; explicitly cancel or erase only at the operation-specific authority boundary.
- Concurrency
- Serialize mutable session capabilities; immutable values follow the language type system.
- Cancellation
- The host owns async cancellation; use cancel, shutdown, close, or erase only where exposed.
Exact public symbols
- Rust
- Android
- Swift
- TypeScript
parse_attachment_metadataAttachmentMetadataAttachmentMetadata.parseAttachmentMetadata.init(tagValues:)AttachmentMetadata.parseParse untrusted imeta tag values before using a URL, media type, size, hash,
dimensions, duration, blurhash, or encryption fields. Unknown or malformed
fields fail under the bounded Softchat profile. Waveform samples stay local
and are not emitted in the interoperable tag.
- Rust
- Android
- Swift
- TypeScript
let metadata = parse_attachment_metadata(vec![
"url https://cdn.example/file".into(),
"m image/jpeg".into(),
"dim 1200x800".into(),
])?;
val metadata = AttachmentMetadata.parse(
listOf(
"url https://cdn.example/file",
"m image/jpeg",
"dim 1200x800",
),
)
Use the validated URL only after the application’s network policy accepts it.
let metadata = try AttachmentMetadata(tagValues: [
"url https://cdn.example/file",
"m image/jpeg",
"dim 1200x800",
])
const metadata = AttachmentMetadata.parse([
"url https://cdn.example/file",
"m image/jpeg",
"dim 1200x800",
]);
Stream encryption
media.encryptStream attachment encryption into private staging.
- Inputs and exact bounds
- chunks at most 1 MiB
- stream at most 16 GiB
- Output
- Nonce, ciphertext chunks, final tag, and hashes.
- Trust and authentication
- Output is private staging until finish succeeds.
- Stable errors
attachment_too_large,attachment_encryption_failed· handling and retry rules
Ownership, lifecycle, and execution rules
- Ownership
- Rust owns protocol semantics, Android account SQLite/use-case state, and optional native relay I/O; the host owns account lifecycle, HTTP/media/background effects, files, and UI.
- Retry
- Retry only host transport or explicitly retryable session work; validation and authentication failures are permanent for the same input.
- Lifecycle
- Language ownership is automatic; explicitly cancel or erase only at the operation-specific authority boundary.
- Concurrency
- Serialize mutable session capabilities; immutable values follow the language type system.
- Cancellation
- The host owns async cancellation; use cancel, shutdown, close, or erase only where exposed.
Exact public symbols
- Rust
- Android
- Swift
- TypeScript
AttachmentEncryptionStreamAttachmentEncryptionStreamAttachmentEncryptionStreamAttachmentEncryptionStreamCreate a stream with a 32-byte attachment key, persist its nonce with the
encrypted object, feed bounded chunks, and store the final authentication tag
and hashes returned by finish.
- Rust
- Android
- Swift
- TypeScript
let stream = AttachmentEncryptionStream::new(key)?;
let nonce = stream.nonce();
for chunk in plaintext_chunks {
encrypted_file.write_all(&stream.update(chunk)?)?;
}
let final_value = stream.finish()?;
AttachmentEncryptionStream(key).use { stream ->
val nonce = stream.nonce
input.forEachChunk { output.write(stream.update(it)) }
val finalValue = stream.finish()
}
Read and write in bounded chunks on an IO dispatcher. Use a private cache or app-files path; persist transfer state separately for WorkManager restart.
let stream = try AttachmentEncryptionStream(key: key)
let nonce = stream.nonce
for chunk in chunks {
try encryptedHandle.write(contentsOf: stream.update(chunk))
}
let finalValue = try stream.finish()
const stream = new AttachmentEncryptionStream(key);
const nonce = stream.nonce;
for (const chunk of chunks) output.write(stream.update(chunk));
const finalValue = stream.finish();
Transfer chunk ArrayBuffers to a Worker rather than copying them through
multiple application layers.
- Rust
- Android
- Swift
- TypeScript
Authenticate before exposing plaintext
media.decryptStream attachment decryption with final authentication.
- Inputs and exact bounds
- chunks at most 1 MiB
- stream at most 16 GiB
- Output
- Unauthenticated staged chunks then authenticated hashes.
- Trust and authentication
- All update output is untrusted and must be deleted unless finish succeeds.
- Stable errors
attachment_too_large,attachment_decryption_failed· handling and retry rules
Ownership, lifecycle, and execution rules
- Ownership
- Rust owns protocol semantics, Android account SQLite/use-case state, and optional native relay I/O; the host owns account lifecycle, HTTP/media/background effects, files, and UI.
- Retry
- Retry only host transport or explicitly retryable session work; validation and authentication failures are permanent for the same input.
- Lifecycle
- Language ownership is automatic; explicitly cancel or erase only at the operation-specific authority boundary.
- Concurrency
- Serialize mutable session capabilities; immutable values follow the language type system.
- Cancellation
- The host owns async cancellation; use cancel, shutdown, close, or erase only where exposed.
Exact public symbols
- Rust
- Android
- Swift
- TypeScript
AttachmentDecryptionStreamAttachmentDecryptionStreamAttachmentDecryptionStreamAttachmentDecryptionStreamDecryption update returns staged bytes, not trusted media. Write them only to
a private temporary destination. Call finish(authenticationTag) after the
last ciphertext chunk. Only a successful finish permits an atomic move or UI
exposure. On error or cancellation, delete the staging object.
Never stream unauthenticated plaintext directly to a decoder, gallery, media scanner, shared URL, or browser object URL.
- Rust
- Android
- Swift
- TypeScript
let stream = AttachmentDecryptionStream::new(key, nonce)?;
for chunk in ciphertext_chunks {
staging.write_all(&stream.update(chunk)?)?;
}
stream.finish(authentication_tag)?;
atomic_publish(staging)?;
AttachmentDecryptionStream(key, nonce).use { stream ->
encrypted.forEachChunk { staging.write(stream.update(it)) }
stream.finish(authenticationTag)
}
atomicPublish(staging)
Delete the private staging file on cancellation, process loss, or any failure.
let stream = try AttachmentDecryptionStream(key: key, nonce: nonce)
for chunk in chunks {
try staging.write(contentsOf: stream.update(chunk))
}
_ = try stream.finish(authenticationTag: authenticationTag)
try atomicPublish(staging)
const stream = new AttachmentDecryptionStream(key, nonce);
for (const chunk of chunks) staging.write(stream.update(chunk));
stream.finish(authenticationTag);
await atomicPublish(staging);
NIP-98 request plans
media.nip98Plan exact-request NIP-98 authorization without executing HTTP.
- Inputs and exact bounds
- absolute normalized HTTP(S) URL from 1 through 8,192 UTF-8 bytes
- ASCII-letter HTTP method from 1 through 32 bytes
- optional request payload at most 16 MiB
- explicit timestamp from 0 through 9,007,199,254,740,991
- Output
- An immutable authorization header and verified event.
- Trust and authentication
- Redirects require a new exact-URL plan; headers must not be forwarded.
- Stable errors
invalid_http_authorization· handling and retry rules
Ownership, lifecycle, and execution rules
- Ownership
- Rust owns protocol semantics, Android account SQLite/use-case state, and optional native relay I/O; the host owns account lifecycle, HTTP/media/background effects, files, and UI.
- Retry
- Retry only host transport or explicitly retryable session work; validation and authentication failures are permanent for the same input.
- Lifecycle
- Language ownership is automatic; explicitly cancel or erase only at the operation-specific authority boundary.
- Concurrency
- Serialize mutable session capabilities; immutable values follow the language type system.
- Cancellation
- The host owns async cancellation; use cancel, shutdown, close, or erase only where exposed.
Exact public symbols
- Rust
- Android
- Swift
- TypeScript
create_nip98_authorizationHttpAuthorizationPlanSoftchatIdentity.createNip98AuthorizationHttpAuthorizationPlanSoftchatIdentity.createNip98AuthorizationHttpAuthorizationPlanSoftchatIdentity.createNip98AuthorizationHttpAuthorizationPlancreateNip98Authorization binds a signed event to one normalized method, exact
URL, optional payload hash, and caller timestamp. The result contains the
Authorization header and explicitly says that authorization must not be
forwarded across a redirect. Methods are 1–32 ASCII letters, URLs are bounded
to 8 KiB, and this whole-buffer helper hashes at most 16 MiB of request body.
The Android account's media.authorizeUpload(method, url, payloadSha256) accepts
an already streamed lowercase hexadecimal SHA-256, so larger uploads need no
whole-body allocation. Hash the bytes required by the HTTP service's profile:
the released Softrelay /api/v2/files endpoint authenticates the multipart
file part, including the nonce and authentication tag for encrypted files.
It excludes multipart boundaries and headers. Passing a hash of the full
multipart body to that endpoint produces an authentication rejection even when
the SDK signature is valid. A separate full-body digest can still protect the
host's replay/stream integrity. Generic NIP-98 body hashing remains distinct
from this file-service profile.
- Rust
- Android
- Swift
- TypeScript
let plan = create_nip98_authorization(
&identity,
"PUT",
url,
Some(body.as_slice()),
now,
)?;
http.execute(plan.method, plan.url, plan.authorization_header, body)?;
val plan = identity.createNip98Authorization("PUT", url, body, now)
httpClient.execute(plan.method, plan.url, plan.authorizationHeader, body)
Execute the plan with the application’s maintained HTTP client. If the server redirects, strip the header and request a fresh plan for the exact new URL.
let plan = try identity.createNip98Authorization(
method: "PUT", url: url, payload: body, createdAt: now
)
Build a URLRequest from the immutable plan. Keep URLSession delegate and
redirect decisions in the application.
const plan = identity.createNip98Authorization("PUT", url, body, now);
await fetch(plan.url, {
method: plan.method,
headers: { Authorization: plan.authorizationHeader },
body,
redirect: "manual",
});
Do not reuse a plan for another method, URL, payload, redirect target, or timestamp.