Summary
Add support for the ActivityPub Media Upload extension so that Fedify-based servers can accept C2S media uploads from clients. The proposed API mirrors the C2S outbox listeners introduced in #430 (released in Fedify 2.2).
Background
The Media Upload extension is a SocialCG-incubated companion to ActivityPub's C2S protocol. Unlike normal C2S interactions, it does not go through POST /outbox; instead, the actor advertises a dedicated upload endpoint via endpoints.uploadMedia, and clients send a multipart/form-data request containing two fields:
file: the binary media payload
object: an ActivityStreams object “shell” (no id, no url) that the server finalizes
The server responds with one of:
201 Created if the uploaded object is immediately fetchable, with Location pointing at the new object's id
202 Accepted if processing (e.g. transcoding) is still in progress, with Location pointing at the eventual object URL
The W3C wiki page for the extension was last updated in 2017 and never reached recommendation status, but it remains the closest thing to a standardized media upload mechanism for ActivityPub C2S, and supporting it positions Fedify well for the small but growing set of C2S-capable clients (e.g. ActivityPods).
Now that #430 has landed and setOutboxListeners() provides a working pattern for authenticated C2S endpoints, media upload is the natural next gap to fill.
Proposed API
A new setMediaUploader(path, callback) method on the federation builder, returning a setter that accepts a single .authorize() chain (parallel to setOutboxListeners()):
federation
.setMediaUploader(
"/users/{identifier}/media",
async (ctx, file, object) => {
const stored = await uploadToStorage(file);
// Result is fetchable right away → 201 Created
return new Image({
id: ctx.getObjectUri(Image, { uuid: stored.uuid }),
url: new URL(stored.publicUrl),
mediaType: file.type,
name: object.name,
});
// Or, the result is not ready yet (still transcoding, etc.) → 202 Accepted
// return ctx.getObjectUri(Video, { uuid: stored.uuid });
},
)
.authorize(async (ctx, identifier) => {
const token = ctx.request.headers.get("authorization");
return await validateToken(identifier, token);
});
Callback signature
type MediaUploaderCallback<TContextData> = (
ctx: RequestContext<TContextData>,
file: File,
object: vocab.Object,
) => Promise<vocab.Object | URL>;
file is a Web-standard File (returned by Request#formData()), giving the callback name, type, size, arrayBuffer(), and stream() uniformly across Deno, Node.js, and Bun.
object is the parsed AS shell. Since the client may send any subtype of Object and the shell lacks an id, the callback receives the base Object class and narrows with instanceof as needed.
Return value semantics
The return type is the union vocab.Object | URL, which directly encodes whether the uploaded resource is already fetchable or is still being processed in the background. This is the HTTP-level distinction between 201 Created and 202 Accepted; it has nothing to do with JavaScript's async/await (the callback itself is always async because it returns a Promise):
- Returning a
vocab.Object instance (the resource exists and is ready) → respond 201 Created, with Location: <object.id> and the serialized object as the body.
- Returning a
URL instance (the resource will exist at that URL once processing finishes) → respond 202 Accepted, with Location: <returnedURL> and an empty body.
In both cases, the framework writes the Location header automatically.
Actor endpoint advertisement
Once setMediaUploader() is registered, the framework automatically populates endpoints.uploadMedia in actor serialization. If it is not registered, the field is omitted entirely. This mirrors how inbox and outbox URIs are auto-filled today.
Error responses
Standard handling for malformed input:
- Non-
multipart/form-data request → 415 Unsupported Media Type
- Missing
file field → 400 Bad Request
- Missing or unparseable
object field → 400 Bad Request
.authorize() returns false → 401 Unauthorized (overridable via onUnauthorized, same as outbox)
Validation and lint rule
The framework verifies that the callback's return value points at a registered object dispatcher route:
- When a
vocab.Object is returned, object.id is checked against registered object dispatcher paths.
- When a
URL is returned, the URL itself is checked the same way.
A mismatch produces a runtime warning (the upload still succeeds) and a corresponding @fedify/lint rule statically flags returns that are not derived from ctx.getObjectUri().
Out of scope
The following are intentionally left as the application's responsibility, consistent with the philosophy adopted in #430:
- Wrapping the uploaded object in a
Create activity: the spec permits the server to auto-publish to the outbox, but doing so silently would surprise users and is trivially implemented by the developer when desired.
- Registering an object dispatcher for the uploaded type: the upload endpoint emits IDs, but serving those IDs back as fetchable AS objects is the developer's job via
setObjectDispatcher(). The framework only warns on apparent mismatches.
- Multi-file uploads: the spec defines a single
file field, so the API accepts exactly one file per request. Clients that need batch uploads issue multiple requests.
Scope/dependencies
Summary
Add support for the ActivityPub Media Upload extension so that Fedify-based servers can accept C2S media uploads from clients. The proposed API mirrors the C2S outbox listeners introduced in #430 (released in Fedify 2.2).
Background
The Media Upload extension is a SocialCG-incubated companion to ActivityPub's C2S protocol. Unlike normal C2S interactions, it does not go through
POST /outbox; instead, the actor advertises a dedicated upload endpoint viaendpoints.uploadMedia, and clients send amultipart/form-datarequest containing two fields:file: the binary media payloadobject: an ActivityStreams object “shell” (noid, nourl) that the server finalizesThe server responds with one of:
201 Createdif the uploaded object is immediately fetchable, withLocationpointing at the new object'sid202 Acceptedif processing (e.g. transcoding) is still in progress, withLocationpointing at the eventual object URLThe W3C wiki page for the extension was last updated in 2017 and never reached recommendation status, but it remains the closest thing to a standardized media upload mechanism for ActivityPub C2S, and supporting it positions Fedify well for the small but growing set of C2S-capable clients (e.g. ActivityPods).
Now that #430 has landed and
setOutboxListeners()provides a working pattern for authenticated C2S endpoints, media upload is the natural next gap to fill.Proposed API
A new
setMediaUploader(path, callback)method on the federation builder, returning a setter that accepts a single.authorize()chain (parallel tosetOutboxListeners()):Callback signature
fileis a Web-standardFile(returned byRequest#formData()), giving the callbackname,type,size,arrayBuffer(), andstream()uniformly across Deno, Node.js, and Bun.objectis the parsed AS shell. Since the client may send any subtype ofObjectand the shell lacks anid, the callback receives the baseObjectclass and narrows withinstanceofas needed.Return value semantics
The return type is the union
vocab.Object | URL, which directly encodes whether the uploaded resource is already fetchable or is still being processed in the background. This is the HTTP-level distinction between201 Createdand202 Accepted; it has nothing to do with JavaScript'sasync/await(the callback itself is always async because it returns aPromise):vocab.Objectinstance (the resource exists and is ready) → respond201 Created, withLocation: <object.id>and the serialized object as the body.URLinstance (the resource will exist at that URL once processing finishes) → respond202 Accepted, withLocation: <returnedURL>and an empty body.In both cases, the framework writes the
Locationheader automatically.Actor endpoint advertisement
Once
setMediaUploader()is registered, the framework automatically populatesendpoints.uploadMediain actor serialization. If it is not registered, the field is omitted entirely. This mirrors howinboxandoutboxURIs are auto-filled today.Error responses
Standard handling for malformed input:
multipart/form-datarequest →415 Unsupported Media Typefilefield →400 Bad Requestobjectfield →400 Bad Request.authorize()returnsfalse→401 Unauthorized(overridable viaonUnauthorized, same as outbox)Validation and lint rule
The framework verifies that the callback's return value points at a registered object dispatcher route:
vocab.Objectis returned,object.idis checked against registered object dispatcher paths.URLis returned, the URL itself is checked the same way.A mismatch produces a runtime warning (the upload still succeeds) and a corresponding
@fedify/lintrule statically flags returns that are not derived fromctx.getObjectUri().Out of scope
The following are intentionally left as the application's responsibility, consistent with the philosophy adopted in #430:
Createactivity: the spec permits the server to auto-publish to the outbox, but doing so silently would surprise users and is trivially implemented by the developer when desired.setObjectDispatcher(). The framework only warns on apparent mismatches.filefield, so the API accepts exactly one file per request. Clients that need batch uploads issue multiple requests.Scope/dependencies
RequestContextparameter shape).@fedify/lintgains one new rule for thegetObjectUrireturn-value check.