The profile API powers a user's profile service on the node.you platform. Every profile exists as its own isolated instance running in a Docker container with its own mounted SQLite database. It handles posts, comments, media, voting, and identity resolution.
Profiles are designed to be publicly viewable on the web. Anyone with a link that visits your profile page can see your public posts, no authentication necessary.
However, as a node.you service, only user's that you have verified can interact on your profile. Verified interactions include commenting and voting.
Keep in mind that the identities of verified users are protected with comment anonymization. Using a mechanism for identity resolution across the network, only viewers verified by the commenter can see the commenter's true identity.
Each profile is an independent FastAPI service provisioned by the node.you API. The main API assigns a subdomain, spins up a Docker container, and routes traffic via nginx. From there, the profile operates autonomously.
+---------------------------------------+
| node.you API |
+-------------------+-------------------+
|
+------+------+
V V
+------------------+ +------------------+
| Service | | Service |
| Server 1 | | Server 2 |
| | | |
| +-------------+ | | +--------------+ |
| |Profile: | | | |Other Service | |
| |tom.node.you | | | +--------------+ |
| +-------------+ | | |
| | | +--------------+ |
| +--------------+ | | |Profile: | |
| |Other Service | | | |ryan.node.you | |
| +--------------+ | | +--------------+ |
+------------------+ +------------------+
Profiles don't use passwords. Authentication relies on Ed25519 signatures produced by the user's node.
There are two sign-in flows:
Manual flow — The user pastes a signed payload into the login dialog. The profile verifies the signature and issues a JWT session.
Challenge flow — The profile generates a challenge and presents a QR code (or link). The user's node scans it and POSTs a signed response to a callback URL. A WebSocket connection delivers the session to the waiting client.
Client Profile API Client's Node
| | |
| POST: | |
| /api/auth/challenge | |
|--------------------------->>| |
| | |
| {challenge_id,callback_url} | |
|<<---------------------------| |
| | |
| WS: | |
| /api/auth/challenge/{id}/ws | |
|============================>| |
| (waiting for auth...) | |
| | POST callback_url |
| | (send signature) |
| |<<----------------------|
| | Profile verifies user, |
| | checks verifications, |
| | creates JWT session |
| { session token } | |
|<<===========================| |
| | |
After authentication, the profile checks with the owner's node API (/api/verifications/check) to confirm the user is in the owner's verification list. Only the owner and verified users get session tokens.
Comments are anonymous by default. Each commenter is assigned a deterministic anonymous ID (e.g., 7X3K9M2P) derived from their public key and a per-profile salt. This keeps identities consistent within a profile while preventing cross-profile tracking.
To reveal a commenter's identity, the client calls /api/comments/resolve-identities with the viewer's public key and a list of anonymous IDs. The profile then contacts each commenter's node to check if the viewer is verified:
Viewer's Client Profile API Commenter's Node
| | (activistguy.node.you)
| POST: | |
| resolve-identities | |
| { viewer_key, anon_ids } | |
|------------------------>>| |
| | GET: |
| | /api/verifications/ |
| | check?public_key=... |
| |-------------------->>|
| | |
| | { verified: true } |
| |<<--------------------|
| | |
| { "7X3K9M2P": "https:// | |
| activistguy.node.you" } | |
|<<------------------------| |
The profile only reveals the identity if the commenter has verified the viewer. The profile owner always sees all identities. Commenters always see their own.
Uploaded media goes through a two-stage process: staging then claiming.
/api/uploads, processed (EXIF stripped, resized, thumbnails generated, video transcoded to H.264), and stored in a temp directory. The client receives an upload_key.upload_keys. The profile moves files from temp to permanent storage and creates attachment records.Unclaimed uploads expire after 24 hours.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST |
/api/auth/session |
No | Create session (manual signed payload) |
GET |
/api/auth/me |
No | Get profile URL and owner info |
POST |
/api/auth/challenge |
No | Start challenge-based sign-in |
GET |
/api/auth/challenge/{id} |
No | Poll challenge status |
WS |
/api/auth/challenge/{id}/ws |
No | WebSocket — wait for challenge completion |
POST |
/api/auth/challenge/{id}/complete |
No | Callback for node to submit signed payload |
POST /api/auth/session{
"public_key": "f13CFZY6Yq+RPkY6...",
"url": "https://alice.node.you",
"timestamp": "2026-03-15T12:00:00Z",
"destination": "https://bob.node.you",
"signature": "v7hT2GqS74CLy/u3..."
}
The signed message format is {timestamp}:{destination}. Returns a JWT session token with access level (is_owner, is_verified).
POST /api/auth/challengeReturns:
{
"challenge_id": "550e8400-...",
"destination": "https://bob.node.you",
"callback_url": "https://bob.node.you/api/auth/challenge/550e8400-.../complete",
"expires_at": "2026-03-15T12:05:00Z"
}
Challenges expire after 5 minutes.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET |
/api/profile |
No | Get public profile |
PUT |
/api/profile |
Owner | Update display name / bio |
GET |
/api/profile/picture |
No | Serve profile picture |
PUT |
/api/profile/picture |
Owner | Upload / replace profile picture |
DELETE |
/api/profile/picture |
Owner | Remove profile picture |
GET /api/profile{
"display_name": "Bob",
"bio": "Building things.",
"picture_url": "/api/profile/picture",
"updated_at": "2026-03-15T12:00:00Z"
}
PUT /api/profile/pictureMultipart file upload. Accepts JPEG, PNG, WebP, GIF. Max 5 MB.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET |
/api/posts |
No* | List published posts |
GET |
/api/posts/drafts |
Owner | List drafts with attachments |
GET |
/api/posts/{id} |
No* | Get post with attachments |
POST |
/api/posts |
Owner | Create post (draft or published) |
PUT |
/api/posts/{id} |
Owner | Update post |
POST |
/api/posts/{id}/publish |
Owner | Publish a draft |
POST |
/api/posts/{id}/unpublish |
Owner | Revert to draft |
DELETE |
/api/posts/{id} |
Owner | Delete post and attachments |
*Private posts require a verified session.
POST /api/posts{
"title": "First post",
"body": "Hello world.",
"is_private": false,
"status": "published",
"upload_keys": ["550e8400-..."],
"links": ["https://example.com"]
}
Posts are auto-upvoted by the owner on publish. Attachments are claimed from staged uploads via upload_keys.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET |
/api/feed |
No* | Get feed of published posts with comment counts |
GET |
/api/feed/{post_id} |
No* | Get post with full threaded comment tree |
*Private posts require a verified session.
GET /api/feedQuery params: skip, limit (max 100), sort_by (created_at or vote_score).
Returns posts with comment_count, user_vote, and attachment thumbnails.
GET /api/feed/{post_id}Query params: comment_sort (created_at or vote_score).
Returns the post with a fully threaded, anonymized comment tree. Top-level comments sort by the chosen field; replies always sort chronologically.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET |
/api/posts/{post_id}/comments |
No | List anonymized comments |
POST |
/api/posts/{post_id}/comments |
Verified | Create comment (with optional attachments) |
DELETE |
/api/posts/{post_id}/comments/{id} |
Author/Owner | Delete comment and attachments |
POST /api/posts/{post_id}/comments{
"content": "Great post!",
"parent_id": null,
"upload_keys": [],
"links": []
}
Set parent_id to reply to another comment. Comments are auto-upvoted by the author. Max 4 images OR 1 video, max 4 links.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST |
/api/comments/resolve-identities |
No | Resolve anonymous IDs to real identities |
POST /api/comments/resolve-identities{
"viewer_public_key": "f13CFZY6Yq+RPkY6...",
"anon_ids": ["7X3K9M2P", "A1B2C3D4"]
}
Returns:
{
"identities": {
"7X3K9M2P": "https://activistguy.node.you",
"A1B2C3D4": null
}
}
A null value means the commenter has not verified the viewer (or the commenter's node was unreachable).
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST |
/api/votes/{target_type}/{target_id} |
Verified | Vote on a post or comment |
DELETE |
/api/votes/{target_type}/{target_id} |
Verified | Remove vote |
target_type is post or comment. Vote value must be 1 or -1. Updating an existing vote is handled automatically.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST |
/api/uploads |
Verified | Upload and process a media file |
DELETE |
/api/uploads/{upload_key} |
Verified | Cancel staged upload |
GET |
/api/uploads/temp/{filename} |
No | Preview staged file |
POST /api/uploadsMultipart file upload. Returns:
{
"upload_key": "550e8400-...",
"media_type": "image",
"mime_type": "image/jpeg",
"file_size": 245760,
"width": 1920,
"height": 1080,
"thumbnail_url": "https://bob.node.you/api/uploads/temp/thumb_...",
"expires_at": "2026-03-16T12:00:00Z"
}
Supported images: JPEG, PNG, GIF, WebP. Supported video: MP4, WebM, MOV (transcoded to H.264/AAC MP4).
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET |
/api/media/{filename} |
No | Serve permanent media file |
Files are served with Cache-Control: public, max-age=31536000, immutable since they're identified by UUID and never change.
| Posts | Comments | |
|---|---|---|
| Images | 20 | 4 |
| Videos | 1 | 1 |
| Links | 10 | 4 |
Images and videos cannot be mixed in the same post or comment.
ProfileUpdate:
display_name: str | None
bio: str | None
ProfileResponse:
display_name: str
bio: str
picture_url: str | None
updated_at: datetime
PostCreate:
title: str # 1–300 chars
body: str | None # max 40,000 chars
is_private: bool = False
status: "draft" | "published"
upload_keys: list[str]
links: list[str]
PostResponse:
id: int
title: str
body: str | None
status: "draft" | "published"
created_at: datetime
published_at: datetime | None
vote_score: int
is_private: bool
CommentCreate:
content: str # 1–10,000 chars
parent_id: int | None
upload_keys: list[str]
links: list[str]
AnonymizedCommentResponse:
id: int
post_id: int
parent_id: int | None
anon_id: str # e.g., "7X3K9M2P"
content: str
created_at: datetime
vote_score: int
user_vote: int | None
author_url: str | None # populated after identity resolution
attachments: list[AttachmentBrief]
AttachmentResponse:
id: int
target_type: "post" | "comment"
target_id: int
attachment_type: "image" | "video" | "link"
position: int
filename: str | None
mime_type: str | None
file_size: int | None
width: int | None
height: int | None
media_url: str | None
thumbnail_url: str | None
url: str | None # for link attachments
AttachmentBrief:
id: int
attachment_type: "image" | "video" | "link"
position: int
media_url: str | None
thumbnail_url: str | None
url: str | None
width: int | None
height: int | None
VoteCreate:
value: 1 | -1
VoteResponse:
id: int
target_type: str
target_id: int
voter_public_key: str
voter_url: str
value: int
created_at: datetime
UploadResponse:
upload_key: str
media_type: "image" | "video"
mime_type: str
file_size: int
width: int | None
height: int | None
thumbnail_url: str
expires_at: datetime
SessionRequest:
public_key: str
url: str
timestamp: str
destination: str
signature: str
SessionResponse:
token: str
expires_in: int # seconds
is_owner: bool
is_verified: bool
public_key: str
url: str
app/
├── routers/
│ ├── comments.py # Comment CRUD (anonymized)
│ ├── feed.py # Feed with threaded comments
│ ├── identity.py # Anonymous ID resolution
│ ├── media.py # Permanent media serving
│ ├── posts.py # Post CRUD + publish/unpublish
│ ├── profile.py # Profile info + picture
│ ├── session.py # Auth sessions + challenge flow
│ ├── uploads.py # Media upload staging
│ └── votes.py # Voting on posts/comments
├── schemas/
│ ├── attachment.py
│ ├── comment.py
│ ├── post.py
│ ├── profile.py
│ ├── upload.py
│ └── vote.py
├── crud/ # Database operations
├── models/ # SQLAlchemy models
├── services/
│ └── attachments.py # Upload claiming + file management
├── dependencies/
│ ├── auth.py # Session management + auth guards
│ └── challenge.py # In-memory challenge store
├── utils/
│ ├── anonymization.py # Deterministic anonymous ID generation
│ ├── crypto.py # Ed25519 signature verification
│ └── media.py # Image/video processing
├── config.py # Settings (env-based)
└── database.py # Async SQLAlchemy (SQLite)
Settings are loaded from environment variables (or .env):
| Variable | Required | Description |
|---|---|---|
OWNER_PUBLIC_KEY |
Yes | Ed25519 public key of the profile owner |
PROFILE_URL |
Yes | This profile's public URL (e.g., https://bob.node.you) |
NODE_API_URL |
Yes | Owner's node API URL for verification lookups |
JWT_SECRET |
Yes | Secret for signing session JWTs |
DATABASE_PATH |
No | SQLite path (default: profile.db) |
JWT_EXPIRY_HOURS |
No | Session duration (default: 24) |
CORS_ORIGINS |
No | Allowed origins (default: *) |
SIGNATURE_TOLERANCE |
No | Max signature age in seconds (default: 300) |
ANON_SALT |
No | Salt for anonymous ID generation |
NODE_SUBDOMAIN |
No | Dev-only subdomain override |
| Component | Technology |
|---|---|
| Framework | FastAPI |
| Database | SQLite (async, via SQLAlchemy) |
| Migrations | Alembic |
| Auth | Ed25519 signatures + JWT sessions |
| Identity | Cryptographic anonymization (SHA-256 + base36) |
| Media | Pillow (images), ffmpeg (video transcoding) |
| Container | Docker (Python 3.12-slim) |
This project is licensed under the AGPL 3.0.