Skip to main content

Content visibility

The DFOS API follows the visibility choices spaces and authors make in DFOS and cannot be used to bypass them. Every resource carries its own gate; nothing is public by inference. The rules below describe the anonymous surface, which is most of the API.

Two gates on every read

A resource inside a space passes two gates: the space must have a public profile, and the resource must be published in its own right. Without the public profile the space's whole public surface goes dark at once, and turning the profile back on republishes nothing. The cross-space listings (GET /v1/spaces, GET /v1/events/upcoming) apply a stricter first gate: a space must be publicly discoverable to appear there, so a space can be addressable by anyone holding its identifier while staying out of every public index.

One 404 for everything hidden

A private resource, a deleted one, an unknown identifier, a malformed identifier, and an identifier belonging to a different space all return the same 404 Not Found, and the API never says which. Listings answer with emptiness instead: a filter naming something you cannot see returns an empty page, so an empty result and a filtered-out result look alike too.

What each resource requires

ResourceServed only when
Spaceit has a public profile. Responses carry public profile and site information only — no membership, private activity, or viewer-specific state.
Postit is shared to everyone, directly or in a public topic.
Pagethe space has enabled it. Pages have no fold; a served page comes back whole.
Topicit is live, carries a public post feed, and is readable by everyone. A chat topic never becomes an addressable public topic, even when opened to everyone.
Eventthe space published it to everyone. Drafts, member-only events, and cancelled events are absent.
Productits seller turned on its public purchase page and it is active, not archived, and addressable by a slug.
Releasethe space published it and it is addressable by a slug. Its composed products, media, and events each stay behind their own rule, so an entry a caller cannot see is simply absent.
  • Topics. Filtering the posts listing by a topic you cannot see returns an empty page rather than a 404 — fetch the topic itself to tell "no public posts yet" from "not a public topic". A space with its Blog turned off serves no topics at all. See Topics.
  • Events. Attendee lists and RSVPs never reach this API; the only attendance signal is an aggregate count of members who marked interest, and event cover images are always public and permanently hosted. GET /v1/spaces/{space}/events and GET /v1/spaces/{space}/events/{eventId} need a public profile and return 404 without one, the single-event read collapsing "no such event" into that same 404. GET /v1/events/upcoming has no not-found at all: a space that is not publicly discoverable never contributes to it. See Events.
  • Products. An unpublished catalog is an empty list, not an error. Everything a buyer receives after paying — deliverable files, confirmation instructions, post-purchase links — is never served here, before or after a purchase. Public ordering position is counted over the visible products only, running 0, 1, 2, … with no gaps. See Products.
  • Pages. Inline media follows the same two classes as post media. See Pages.

Posts

A post containing a fold marker (<!--fold-->) returns only the content above the fold, omits inline media that appears only below it, and sets folded to true. Media on a readable post comes back inline in the two classes described in Media URLs. List and single-post endpoints apply identical rules: a post that cannot appear in a space's public feed returns 404 Not Found when fetched directly.

The credential-gated routes

These serve the granting user's own data to an app they consented to, and are the only places on this API where private data is served at all:

  • GET /v1/profile — that user's own profile, including their email address.
  • The membership routes — the spaces and groups they belong to, naming private spaces that nothing else on this API will confirm exist.
  • GET /v1/credential — the presented credential and nothing else.
  • GET /v1/feed, reached by read:posts — what the granting user can see in the spaces the grant reaches, including private spaces and paid content they hold.
  • The post comments route, also read:posts — the thread as the granting user sees it, including in a private space the user belongs to.

They keep the collapsed 404. GET /v1/membership/{space} and GET /v1/group-membership/{group} answer byte-identically for "there is no such space" and "your user is not a member of it"; the read:posts content routes return one 404 for a grant that does not reach a post's space, a post in another space, a post the user cannot read, and a post that does not exist.

A credential widens what a member can address; it never widens existence

GET /v1/spaces/{space}/posts and GET /v1/spaces/{space}/posts/{postId} serve the anonymous projection to everybody, and the granting user's projection to a caller whose credential covers that space with read:posts.

  • A private space is addressable by its own members. On the routes that accept a credential — posts, comments, and media — a space with no public profile resolves for a caller whose credential covers it — or who presents a bare identity proof — and who is an active member of it. The anonymous routes (the space profile, pages, topics, events, products, releases) still need a public profile. Everyone else gets the same 404 as a space that does not exist: an anonymous caller, a grant that does not reach it, a user who is no longer active there. A broken or revoked credential is refused the same way on a private space as on a missing one, so the status code says nothing about whether the space is real.
  • A grant that does not cover the space returns the anonymous bytes — not a 403, the identical response — as does a covering grant whose user is no longer an active member there. A credential is a ceiling on what an app may read; the user's own standing is the floor. Losing access looks like silence, so read your coverage from GET /v1/credential.
  • The fold rule is unchanged. An anonymous read still stops at the fold; a member projection carries the full body and every attachment only where the granting user genuinely reads the post.

A space id learned from a membership response is still just an id: that space's pages and members stay unreadable, and its posts open only to a grant that names it. Every other endpoint stays anonymous and viewer-independent whether or not a credential is presented. See Authentication.

Writing never widens existence either

Writes sit behind the same two gates and collapse into the same 404: a post in another space, a post in a topic the granting user cannot reach, and a post that does not exist are one answer. The addressed space is a different question, and its status follows the space's visibility: on a space with a public profile, a grant that does not cover it is a 403, and so is not being a member — never the anonymous projection, because there is no anonymous write to degrade to. On a space without one, both are the same 404 as a space that does not exist, and a replayed write is a 409 only for a member. Writes also reach less than reads: a grant covering a space with write:posts reaches the granting user's own posts there and nothing else, and another member's content is 403 whatever that user's role. Nothing in this API moderates.

A bare identity proof writes as well as it reads: a signing key added under Settings → DFOS Protocol can post, comment, and upvote as you with no credential and no consent screen. There is nothing in the DFOS app to revoke: revocation is a chain operation. Remove the key from your identity and the next proof it signs stops verifying, so a leaked signing key is full write authority as you until you do. An exit key cannot write: a key whose only role is controller is a continuity instrument, and this API answers 403 when one signs a write. Use an ordinary signing key for content.