TuneCamp uses a self-sovereign, decentralized identity model powered by FID (Fediverse-ID) (fid): an Ed25519 keypair derived in the browser from an alias and a passphrase, signed requests over plain HTTPS, and no relay.
This architecture allows users to unify their profiles across independent TuneCamp instances without relying on a centralized Single Sign-On (SSO) or shared database.
The official central SSO and identity portal is deployed at:
👉 https://fid-portal.vercel.app/ (or tunecamp.org)
┌───────────────────────────┐
│ fid-portal.vercel.app │
│ (Ed25519 identity, │
│ keys stay in browser) │
└─────────────┬─────────────┘
│ HTTPS (signed requests)
┌─────────────────────┴─────────────────────┐
│ │
┌────────────▼────────────┐ ┌────────────▼────────────┐
│ TuneCamp Instance A │ │ TuneCamp Instance B │
│ (sudorecords.scobru...) │ │ (tunecamp.subterra...) │
└─────────────────────────┘ └─────────────────────────┘
-
Step 1 (Instance
$\rightarrow$ fid-portal.vercel.app):- On local TuneCamp settings, user clicks "Genera Challenge di Vincolo" (
GET /api/auth/zen/challenge). - Instance generates a one-time challenge nonce
{ instanceDomain, username, nonce, timestamp }. - User copies the Challenge JSON.
- On local TuneCamp settings, user clicks "Genera Challenge di Vincolo" (
-
Step 2 (fid-portal.vercel.app
$\rightarrow$ Instance):- On
fid-portal.vercel.app/profile.html, user opens "Link Instance"$\rightarrow$ "Firma Challenge Istanza". - User pastes the Challenge JSON.
- Portal signs the challenge with the user's private identity key and generates a Passport JSON.
- User copies the Passport JSON and pastes it back into the local TuneCamp instance to activate the verified link.
- On
fid 5.0 replaced Zen SEA keys with Ed25519 keys, so the same alias and passphrase now derive a different key. An account whose zen_pub is the old key is no longer reached by signing in: the SSO answers Username already exists… (FID_KEY_CHANGED) or the link flow FID identity not found.
Accounts created through FID have no password, and the server refuses to write one, and the old key can no longer be verified (Zen is gone), so the owner cannot prove the account is theirs by themselves. An administrator has to approve the move.
Still signed in on the instance (self-service). Open Profile and press Link my FID identity to this account. The instance mints a one-time code (5 minutes, single use) and opens tunecamp.org/profile.html with it in the URL fragment. Sign in there with your alias and passphrase and press Link this identity: the page signs the instance challenge with the new key and binds it to the account, no administrator involved.
From the web UI. The owner signs in through the FID portal with the new key. The instance answers FID_KEY_CHANGED and records a relink request on the account (the first request wins; a different key cannot replace it until the administrator dismisses it). In Admin → Users the account shows FID relink requested with the key and Approve / Dismiss buttons (primary admin only). Confirm with the person that the key is the one their portal shows before approving: anyone who knows a username can send a request. After approval the owner signs in again through the SSO.
From the host. The same move without the UI:
npm run fid:relink -- <username> <new-public-key> # add --db path/to/db if it is not the configured oneThe new public key is the one the FID profile page shows after signing in again. The tool refuses a malformed key or one another account owns, signs the account's sessions out, resets its passports (they were issued for the old key) and removes the old identity's library records, which nobody can read any more. Accounts that do have a password can log in normally and link the new key from their profile.
- Endpoint:
GET /api/auth/zen/challenge - Auth Required: Yes (
requireUser) - Response:
{
"success": true,
"challenge": {
"instanceDomain": "sudorecords.scobrudot.dev",
"username": "scobru",
"nonce": "a4f891b2c3d4e5f67890123456789abc",
"timestamp": 1721926658000
}
}- Endpoint:
POST /api/auth/zen/link - Auth Required: Yes (
requireUser) - Body:
{
"zenPubKey": "bE9DAycqb9gbxMJHTxh5RRxVQRPpG-wrCojHVl0s9sM",
"challenge": { ... },
"seaSignature": "SEA.sign_signature_data"
}- Response:
{
"success": true,
"passport": {
"instanceDomain": "sudorecords.scobrudot.dev",
"localUsername": "scobru",
"zenPubKey": "bE9DAycqb9gbxMJHTxh5RRxVQRPpG-wrCojHVl0s9sM",
"issuedAt": 1721926658000,
"passportSignature": "HMAC_SHA256_SIGNATURE",
"publicDataEndpoint": "https://sudorecords.scobrudot.dev/api/auth/zen/user/scobru/public"
}
}- Endpoint:
POST /api/auth/zen/sso - Auth Required: No (Public Rate-Limited)
- Body:
{
"ssoToken": {
"clientId": "tunecamp-webapp",
"instanceDomain": "sudorecords.scobrudot.dev",
"username": "scobru",
"zenPubKey": "bE9DAycqb9gbxMJHTxh5RRxVQRPpG-wrCojHVl0s9sM",
"issuedAt": 1721926658000
},
"apSeed": "32_byte_hex_seed..."
}- Behavior:
- Validates
ssoTokenviaFidSsoHandler.validateSsoToken(). - Resolves the account by the identity key the signature was actually verified against —
masterKeySource.pubKeywhen present, otherwise the flatzenPubKey. A token that carries both and lets them disagree is refused (400 SSO token identity mismatch): those are two independent fields on one wire payload, so trusting a key other than the verified one would let any keypair claim any account. Requiresfid≥ 4.0.1, which refuses such a token itself; this route repeats the check so it does not depend on the library for it. - Derives deterministic Ed25519 ActivityPub keys server-side from
apSeed. - Persists those keys on the account (
admin.ap_public_key/ap_private_key) when not already set, and on the linked artist when there is one. Without this the account would have no Fediverse actor at all: SSO never goes throughPOST /api/auth/login, which is where key generation is otherwise triggered. - New SSO users start as standard Listeners (
UserRole.NORMAL_USER) without auto-created artist profiles. - If promoted internally by instance admins, their instance-assigned role/artist link is respected.
- Validates
- Endpoint:
GET /api/auth/zen/user/:username/public - Auth Required: No (Public)
- Response: Returns only public profile info, public releases, and public playlists for cross-instance aggregation on
fid-portal.vercel.app.
- Endpoint:
GET /api/auth/zen/instances - Auth Required: Yes (
requireUser) - Response: Returns the user's
fid_registryentries (linked instances with artist info, passport signatures, verification status). - Purpose: Allows the global portal to discover which instances a user has linked without querying every instance.
- Table:
fid_registry(per-instance, tracks linked instances per user) - Endpoints: Removed - cross-instance linking now handled externally at
tunecamp.org/profile.html - Flow: User authenticates on Instance A, gets passport from
tunecamp.org/profile.htmlvia FID portal, then links via external profile page.
- Auth Header:
Authorization: FID <zen_pub_key> - Middleware:
requireFidAuthinauth.ts - Behavior: Looks up user by
zen_pubkey, derives context, grants access to MCP tools (search_music, list_recent_albums, scan_library, get_system_stats) without JWT. - Use Case: AI assistants (Claude Desktop, etc.) authenticate via user's FID identity to inspect/manage catalog across instances.
- Data Source: Aggregates
publicReleases,publicLikes,publicPlaylistsfrom all linked instances via their/api/auth/zen/user/:username/publicendpoints. - Storage: Caches per-instance data in
localStorage(tunecamp_instance_data). - Tabs: Releases, Favorites (starred), Playlists — each shows instance badge.
- Auto-sync: On login,
loadLinkedInstances()fetches registry and auto-syncs verified instances. - Manual sync: "Sync" button per instance in the Linked Instances list.
- Library sync (player): when an identity is linked to an instance, the player can mirror the listener's library (favorites, artists, playlists) to that instance. Records are encrypted in the browser with a key derived from the identity key; the instance only stores ciphertext. See below.
Cross-device sync of a listener's library, plus public shared playlists. Plain HTTP, no relay.
- Endpoints (all under
/api/auth/zen/library/:pub, wildcard CORS, no cookies):GET /:pub?since=<ms>— records changed aftersince, tombstones included. Signed.PUT /:pub— upserts{ records: [{ bucket, id, d, at, del }] }. A record only lands ifatis newer than the stored one (last write wins). Signed.GET /:pub/shared/:id— one public shared playlist{ name, items, at }. Anonymous.GET /:pub/account—{ username }if this instance holds an active account linked to the key, else404. Anonymous; a new device uses it to find where its library lives. It exposes only the key ↔ username link the passport endpoints already publish.
- Auth:
X-Fid-Auth: <ts>.<sig>, wheresigis the identity key's signature overfid-library:<METHOD>:<path>:<ts>:<sha256 hex of the body>. Timestamps more than 5 minutes off are refused. The key must belong to an active account on that instance (admin.zen_pub), so an instance is never free storage for strangers. - Buckets:
favorites,artists,playlists(ciphertext ind) andshared(JSON in the clear, up to 200 tracks). - Limits: 200 records per request, 64 KB per record, 5000 records and 8 MB per identity, 120 requests per minute per IP. Over the limit:
413. - Storage: table
library_sync (pub, bucket, id, d, at, del). A deleted record keeps its row as a tombstone with an empty payload.