The Activity Envelope
Every write to Kowloon goes through POST /outbox with an Activity envelope. This page covers everything that's true for every Activity type before you get into the specifics of any one of them. Read this first -- the per-type pages assume it.
Pipeline
Section titled “Pipeline”POST /outbox -> normalize the envelope (routes/outbox/post.js) -> validate against activity.schema.js (AJV) -> methods/activities/create.js (createActivity) -> ActivityParser/handlers/<Type>/index.js -> persist an Activity document -> optionally enqueue federationActivityParser/index.js itself is just the handler-loading factory: it scans handlers/ and auto-registers each subfolder's default-exported function under its directory name, no central list to maintain. Adding a new handler function really is just dropping a new handlers/Whatever/index.js -- but making that type reachable via POST /outbox still requires adding it to activity.schema.js's type enum by hand, since createActivity() validates against that central schema before it ever looks up a handler.
Two layers of validation
Section titled “Two layers of validation”- Schema-level, once, centrally.
createActivity()validates the whole envelope againstactivity.schema.js(AJV) before dispatching anywhere -- envelope shape,typeenum membership, the addressing grammar, and (forCreate/Reply/Reactspecifically) extra conditional rules like requiringobject.type. - Business-logic, inside each handler, on itself. Several handlers (
Create,Update,Delete,Reply,React,Join,Leave,Undo) export their ownvalidate(activity)function and call it on themselves at the top of their default export, before doing any work. The rest (Add,Remove,Block,Unblock,Mute,Unmute,Flag) skip the separate named function and just do inline guard checks through the handler body -- same effect, less ceremony. This layer covers what AJV can't express: does the target actually exist, is the actor allowed to do this, does a reason code match a configured option.
What happens before validation
Section titled “What happens before validation”routes/outbox/post.js normalizes every incoming request before the schema ever sees it:
- Auth.
POST /outboxrequires a JWT-authenticated user for every Activity exceptCreatewithobjectType: "User"(orobject.typeofUser/Person) -- that's the account-registration path, and it runs unauthenticated withactivity.actorIdforce-set to the server's own actor (@<domain>). - For every other request,
activity.actorIdis always overwritten with the JWT user's id. A client cannot spoofactorId. activity.actor(the embedded actor snapshot) is auto-populated from the JWT user if the client didn't send one:{ id, type, name, icon, url, inbox, outbox, server }.to/canReply/canReactdefault to the actor's own id if absent -- on both the activity itself and, for every type exceptUpdate/Delete, onactivity.objecttoo. (Update/Deletetreatobjectas a patch, not a fresh object, so they're deliberately skipped here.) This makes the safe default actually private:canSeeObject()treats an empty/missingtoas server-wide visible, so before this default was in place, omittingtosilently meant "visible to everyone on the server," not "private." Addressing something only to its own creator's id falls through every other visibility check toreturn false, so nobody but the owner (who's always allowed to see their own content) can see it.- Shorthand values are expanded:
"public"->"@public","server"->"@<domain>". outboxRateLimiterandactivityDeduplicatormiddleware run before the handler (see Idempotency below).
The envelope schema
Section titled “The envelope schema”Validated with AJV against activity.schema.js (https://kwln.org/activity.schema.json). Top level: additionalProperties: true, required: ["type", "actorId"].
type: enum [ "Add", "Block", "Create", "Delete", "Flag", "Join", "Leave", "Mute", "React", "Remove", "Reply", "Unblock", "Undo", "Unmute", "Update"] // 15 values
actorId: anyOf [ "@user@domain", "@domain" (server) ] // always this format -- local AND remote actors, no exceptions
objectType: enum [ "Bookmark", "Circle", "Group", "Page", "Post", "React", "Reply", "User"] // 8 values
object: {} // untyped at the schema level; each handler validates its own shapetarget: { type: "string" }summary: { type: "string" }to / canReply / canReact: "replyReactRecipient" schema (see below)Addressing value grammars
Section titled “Addressing value grammars”to/canReply/canReact are validated against one of two grammars depending on context:
toRecipient (used for Create's to):
"", "@public", coarse "audience"|"public"|"server"|"followers", @<domain> (server handle), circle:...@domain, group:...@domain, @user@domain (actorId), or an https?:// URL.
replyReactRecipient (used for Reply's and React's to, and as the general schema for to/canReply/canReact everywhere else): everything toRecipient accepts, plus "none", post:...@domain, page:...@domain, bookmark:...@domain, reply:...@domain. It accepts direct object-ID targets because for Reply and React, to isn't an audience at all -- it's "what object am I acting on."
ID regex patterns
Section titled “ID regex patterns”actorId: ^@[^@\s]+@[a-z0-9.-]+$serverHandle: ^@[a-z0-9.-]+$publicToken: ^@public$circleId: ^circle:[^@\s]+@[a-z0-9.-]+$groupId: ^group:[^@\s]+@[a-z0-9.-]+$postId: ^post:[^@\s]+@[a-z0-9.-]+$pageId: ^page:[^@\s]+@[a-z0-9.-]+$bookmarkId: ^bookmark:[^@\s]+@[a-z0-9.-]+$objectId: ^(circle|group|post|page|bookmark|reply):[^@\s]+@[a-z0-9.-]+$Conditional validation (allOf)
Section titled “Conditional validation (allOf)”Three blocks tighten the schema further depending on type:
Create-- requires["objectType", "object"];tomust matchtoRecipient(the audience grammar);object.typeis required.Reply-- requires["objectType", "object", "to"];objectTypemust be the literal string"Reply";tomust match theobjectIdpattern (it must be a real post/page/bookmark/reply/circle/group ID -- the parent being replied to);object.type, if present, must be"Reply".React-- requires["objectType", "object", "to"];objectTypemust be the literal string"React";tomust matchobjectId;object.type, if present, must be"React"(deliberately optional -- see React for why).
Response shape
Section titled “Response shape”Every POST /outbox call returns the same envelope shape.
Success (HTTP 200):
{ "ok": true, "activity": { "...": "the persisted Activity envelope, incl. mongo id, dedupeKey, federated flag" }, "result": { "...": "handler-specific result -- see each type's page" }, "createdId": "post:64f...@kwln.org", "federate": false, "duplicated": true, "federationJob": { "jobId": "...", "recipients": 3, "counts": {} }}duplicated is only present when a dedupe hit occurred. federationJob is only present if federation was actually enqueued.
Failure: HTTP status is result.status if the handler set one (e.g. a visibility/block gate returns 404, a disabled canReply/canReact returns 403), otherwise 400. Body: { "error": "..." }.
Idempotency
Section titled “Idempotency”Three independent mechanisms, all checked before the handler runs (the first two) or handled inside the handler itself (the third):
activity.remoteId-- federation-sourced activities dedupe by exactremoteIdmatch.activity.dedupeKey-- a client-supplied idempotency key. The client SDK'screatePost,reply, andcreateBookmarkmethods (among others) all accept one. Dedupes by exact string match againstActivity.dedupeKey.- Reply-specific content dedup -- identical
source.contentfrom the same actor to the same immediateparentwithin a 5-minute window returns the existing Reply instead of creating a duplicate (duplicated: truein the response). This is independent ofdedupeKeyand only applies to Reply.
Create also has its own idempotency backstop against a MongoDB unique-index collision (E11000) for double-submitted creates, returning the existing document rather than erroring. These three-plus-one mechanisms don't overlap -- which one applies depends on the Activity type and how the client submitted the request.
Start with the type you need, or read them in order for the full picture: Create | Update | Delete | Reply | React | Membership (Join/Leave/Add/Remove) | Moderation (Block/Mute) | Undo | Flag | Known gotchas.