-
Notifications
You must be signed in to change notification settings - Fork 8
Description
Very detailed task made by agents in a few takes. Will have inaccuracies, but good starting point to build upon.
⚠️ AI-Generated: May contain errors. Verify before use.
Reviewed by feature-analyzer agent and security-analyst agent.
A technical proposal for implementing a discoverable public space directory in Quorum.
Created: 2025-12-30
Status: Proposal (Requires Backend API Support)
Security Review: 2025-12-30 (Critical issues identified and addressed)
This document outlines the technical requirements for implementing a Public Space Directory - a feature that allows Space owners to list their Spaces publicly, enabling users to discover and join Spaces without needing a direct invite link.
Current State
- Spaces have an
isPublic: booleanflag (defined insrc/api/quorumApi.ts:41) - This flag currently controls whether invite links are "directly joinable" vs requiring manual approval
- There is no discovery mechanism - users must have an invite link to find a Space
- All Space discovery is invite-link based via
JoinSpaceModal
Proposed Feature
A browsable directory where:
- Space owners can opt-in to list their Space publicly
- Users can browse/search for public Spaces
- Users can join directly from the directory (using existing invite flow)
Architecture Context
Quorum's Decentralized vs Coordination Layers
Quorum operates on a decentralized network, but uses a coordination API for certain operations. Understanding this distinction is important for the directory feature:
| Layer | Purpose | Examples |
|---|---|---|
| Decentralized (Hubs) | Message routing, encryption, membership | Space messages, DMs, Triple Ratchet sessions |
| Coordination API | Metadata indexing, user registration, discovery | User registration, space manifests, invite evals, config sync |
The Public Space Directory is a coordination API feature, not a decentralized protocol. This is intentional:
- Directory listings are public metadata that owners opt-in to share
- The coordination API already stores space manifests and handles invite evaluations
- Discovery requires aggregated, searchable data - inherently centralized
- Privacy-sensitive data (messages, keys) remain fully decentralized
Reference: See Data Management Architecture and Cryptographic Architecture for details on Quorum's architecture.
Technical Architecture
Current Relevant Systems
1. Space Data Model (src/api/quorumApi.ts)
export type Space = {
spaceId: string;
spaceName: string;
description?: string; // Already exists for directory listings
vanityUrl: string;
inviteUrl: string; // Public invite URL when enabled
iconUrl: string;
bannerUrl: string;
defaultChannelId: string;
hubAddress: string;
createdDate: number;
modifiedDate: number;
isRepudiable: boolean;
isPublic: boolean; // Exists but not used for discovery
// ...
};2. Space Creation (src/services/SpaceService.ts)
createSpace()acceptsisPublic: booleanparameter (line 127)- Currently only affects invite link behavior, not discoverability
3. Invite System (src/services/InvitationService.ts)
generateNewInviteLink()creates public invite links withconfigKey- Public invite format:
https://qm.one/invite/#spaceId={id}&configKey={key} configKeyis the X448 private key needed to decrypt the Space manifest
4. API Endpoints (src/api/baseTypes.ts)
Current relevant endpoints:
| Endpoint | Purpose |
|---|---|
GET /space/{address} |
Get space registration |
GET /space/{address}/manifest |
Get encrypted space manifest |
POST /space/{address} |
Register/update space |
POST /invite/evals |
Store public invite evaluations |
POST /invite/eval |
Consume one invite evaluation |
5. Authentication Pattern
All API requests use:
- Signature-based auth: Owner signs payloads with Ed448 keys
- Timestamp binding: Prevents replay attacks
- Public key verification: Server verifies signatures
Example from SpaceService.ts:178-189:
const ownerPayload = Buffer.from(
new Uint8Array([
...hexToSpreadArray(spaceKey.publicKey),
...configPair.public_key,
...hexToSpreadArray(ownerKey.publicKey),
...int64ToBytes(ts),
])
).toString('base64');
const ownerSignature = JSON.parse(
ch.js_sign_ed448(
Buffer.from(ownerKey.privateKey, 'hex').toString('base64'),
ownerPayload
)
);Proposed API Endpoints
Authentication: All directory endpoints require user authentication (signed request with user address). Users must be logged into Quorum to access the directory - there is no anonymous browsing. This enables per-user rate limiting and ensures one rating per user per space.
1. List Public Spaces
GET /api/spaces/public
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
search |
string | Search in name/description |
category |
string | Filter by category (e.g., gaming, technology) |
sort |
string | newest, top-rated, popular, name |
limit |
number | Max results (default 50, max 100) |
cursor |
string | Pagination cursor from previous response |
Sort Options:
newest- By listedAt descending (default)top-rated- By averageRating descending (requires min 5 ratings)popular- By memberCount descendingname- Alphabetical by spaceName
Response:
{
spaces: PublicSpaceListing[];
nextCursor: string | null; // Cursor-based pagination (encoded timestamp + spaceId)
total: number; // Approximate count
}
type PublicSpaceListing = {
spaceId: string;
spaceName: string;
description: string;
iconUrl: string;
bannerUrl: string;
memberCount: number;
category: SpaceCategory; // Required category for filtering
// SECURITY: NO configKey here - keys must never be stored in directory!
// PRIVACY: NO ownerAddress here - owner identity must never be exposed!
listedAt: number;
lastUpdatedAt: number;
// Rating data (community moderation)
averageRating: number | null; // null if no ratings yet
ratingCount: number;
};
// Predefined categories (v1)
type SpaceCategory =
| 'gaming'
| 'technology'
| 'music'
| 'art-design'
| 'education'
| 'science'
| 'crypto-web3'
| 'community'
| 'business'
| 'other';
⚠️ SECURITY NOTE: TheconfigKeyis the X448 private key used to decrypt space manifests. It must NEVER be stored in the directory database. See "Secure Join Flow" section below.
2. Publish Space to Directory
POST /api/spaces/{spaceId}/publish
Request Body:
{
space_address: string;
listing_data: {
name: string;
description: string;
icon_url: string;
banner_url: string;
category: SpaceCategory; // Required - owner selects in Space Settings
};
timestamp: number;
owner_public_key: string; // For signature verification only - NOT stored in listing!
owner_signature: string; // Signs: listing_data + timestamp
}PRIVACY NOTE: The
owner_public_keyis used ONLY for signature verification. The API already knows the owner's public key from the original space registration. This key is NEVER stored in or returned from the directory listing - seePublicSpaceListingtype which intentionally excludes owner identity.
Signature Payload:
// Owner must sign to prove ownership
// SECURITY: Include operation type and spaceId to prevent replay attacks
const payload = Buffer.from(
'PUBLISH:' + spaceId + ':' + JSON.stringify(listing_data) + ':' + timestamp
).toString('base64');
const signature = ch.js_sign_ed448(ownerPrivateKey, payload);Replay Attack Prevention: The signature binds to:
- Operation type (
PUBLISH) - prevents using publish signatures for other operations- Space ID - prevents replaying signature for different spaces
- Timestamp - API should reject requests older than 5 minutes
3. Rate Space (User Moderation)
POST /api/spaces/{spaceId}/rate
Users who have joined a space can rate it.
Request Body:
{
user_address: string;
rating: 1 | 2 | 3 | 4 | 5;
timestamp: number;
user_signature: string; // Proves user identity
}Rules:
- One rating per user per space (can update)
- User must have joined the space to rate (verify via hub membership)
- Minimum 7 days membership required before rating (prevents join-rate-leave manipulation)
- Rating updates replace previous rating
Dependency: The 7-day membership requirement depends on the
joinedAtfield being stored for space members. See New Member Badge task which adds this field. If that task is not yet implemented, this rule should be deferred or the API should skip this check untiljoinedAtdata is available.
4. Report Space (Flag for Review)
POST /api/spaces/{spaceId}/report
Request Body:
{
user_address: string;
reason: 'spam' | 'inappropriate' | 'misleading' | 'inactive' | 'other';
details?: string; // Optional additional context
timestamp: number;
user_signature: string;
}Rules:
- One report per user per space (prevents spam reporting)
- Reports accumulate in the coordination API database
- Auto-hide logic (see "Auto-Moderation Rules" below)
5. Update Directory Listing
PATCH /api/spaces/{spaceId}/publish
Same structure as POST, allows updating listing metadata (name, description, category, etc.).
6. Get Directory Invite (Simplified Flow)
GET /api/spaces/{spaceId}/directory-invite
This endpoint returns the space's existing public invite URL. It does NOT create a new invite - it simply provides access to the invite the owner already created.
Response:
{
inviteUrl: string; // The space's existing public invite URL
}API Logic:
- Verify space is published in directory
- Fetch space manifest to get
space.inviteUrl - Return the existing public invite URL
Error Responses:
| Status | Error | Meaning |
|---|---|---|
| 404 | Space not in directory |
Space not published |
| 404 | No public invite |
Space has no public invite configured |
Note: This uses the SAME invite pool as manual sharing. When users join via directory, they consume evals from the owner's existing public invite. If evals run out, joining fails until owner regenerates the invite.
Security Considerations
1. Ownership Verification
- All publish/unpublish operations require owner key signature
- Quorum API verifies signature using registered
owner_public_keyfrom space registration - Prevents unauthorized listing of spaces
2. Config Key Security (CRITICAL)
⚠️ TheconfigKeyis the X448 PRIVATE key, not public key!
Looking at the actual implementation:
// InvitationService.ts:434
space!.inviteUrl = `...&configKey=${Buffer.from(new Uint8Array(configPair.private_key)).toString('hex')}`;Security Requirements:
- ❌ NEVER store
configKeyin the directory database - ❌ NEVER return
configKeyin directory API responses - ✅ Directory stores only public metadata (name, description, icon, owner)
- ✅ Joining requires fetching a one-time invite (see "Secure Join Flow")
- ✅ Each join consumes one eval, preventing unlimited access
3. Rate Limiting
Required Limits:
| Operation | Limit | Scope |
|---|---|---|
GET /spaces/public |
100/minute | Per IP |
POST /spaces/{id}/publish |
5/hour | Per owner |
GET /spaces/{id}/directory-invite |
30/minute | Per IP |
POST /spaces/{id}/rate |
10/minute | Per user |
POST /spaces/{id}/report |
5/hour | Per user |
Implementation:
- Response caching: 30-second cache on directory listings
- Return
Retry-Afterheader when limits exceeded - Consider CloudFlare/similar for DDoS protection
4. Community Moderation (Ratings + Reports)
Rating System:
- Users who joined a space can rate 1-5 stars
- One rating per user per space (can update)
- Displayed as average + count on space cards
Report System:
- Users can report listings for: spam, inappropriate, misleading, inactive, other
- One report per user per space
- Reports trigger auto-moderation rules
Auto-Moderation Rules:
Space is automatically hidden from directory if:
- Average rating < 2.0 AND ratingCount >= 10
- Report count > 20 in rolling 7-day window
- Space is deleted (detected by background job)
Owner Visibility (No Push Notifications):
Since Quorum is privacy-first (no email/phone):
- Owner sees listing status in SpaceSettingsModal → Directory tab
- Status shown: "Listed", "Hidden (low ratings)", "Hidden (reports)"
- Owner can see their current rating and report count
- No way to push-notify owners when they're offline
5. Spam Prevention
Minimum Requirements to List:
- Space must have at least 20 members
- Space must have at least 100 messages in any channel
- Space must be at least 7 days old (createdDate)
These thresholds prevent:
- Spam accounts creating empty spaces just to list
- Low-effort placeholder listings
- Newly created spaces with no real community
6. Background Cleanup Job
The Quorum coordination API runs a periodic job (every hour) to:
- Check if listed spaces still exist (fetch manifest from hub)
- Remove listings for deleted spaces
- Update member counts from hub data
- Recalculate average ratings
Architecture Note: This cleanup job runs on the coordination API layer (the same service that currently handles
/space/{address},/invite/evals, etc. - seesrc/api/baseTypes.ts). It is NOT a decentralized operation - the directory is a coordination service that aggregates publicly-opted-in space metadata.
7. Prerequisite: Public Invite Required
A space can only be listed in the directory if it has an active public invite:
- Owner must first "Generate Public Invite Link" in SpaceSettingsModal
- This creates the invite URL and eval pool
- Only then can they "List in Public Directory"
- If owner has never generated a public invite, listing should be blocked with helpful message
Frontend Implementation (This Repo)
Once backend API is available, the following frontend work is needed:
Space Settings: Directory Tab
Owner configures directory listing in SpaceSettingsModal
Category Display Names
const CATEGORY_LABELS: Record<SpaceCategory, string> = {
'gaming': 'Gaming',
'technology': 'Technology',
'music': 'Music',
'art-design': 'Art & Design',
'education': 'Education',
'science': 'Science',
'crypto-web3': 'Crypto & Web3',
'community': 'Community',
'business': 'Business',
'other': 'Other',
};New Components
| Component | Location | Purpose |
|---|---|---|
ExploreSpaces.tsx |
src/components/pages/ |
Main directory browse page |
SpaceCard.tsx |
src/components/directory/ |
Space listing card |
DirectorySearch.tsx |
src/components/directory/ |
Search/filter UI |
DirectoryTab.tsx |
src/components/modals/SpaceSettingsModal/ |
"List in Directory" toggle |
New Services
| File | Purpose |
|---|---|
DirectoryService.ts |
API calls for directory operations |
New Hooks
| Hook | Purpose |
|---|---|
usePublicSpaces.ts |
Fetch/cache directory listings |
useDirectoryRegistration.ts |
Publish/unpublish space |
Route Addition
// In router config
{
path: '/explore',
element: <ExploreSpaces />
}Data Flow Diagrams
Publishing a Space
┌──────────────────────────────────────────────────────────────────────┐
│ PUBLISH FLOW │
├──────────────────────────────────────────────────────────────────────┤
│ │
│ 1. Owner opens SpaceSettingsModal → DirectoryTab │
│ │
│ 2. Clicks "List in Public Directory" │
│ ↓ │
│ 3. Frontend prepares listing data: │
│ - Collects name, description, icon, banner │
│ - Gets configKey from space_keys (public portion) │
│ - Generates timestamp │
│ ↓ │
│ 4. Signs with owner key: │
│ signature = js_sign_ed448(ownerPrivateKey, payload) │
│ ↓ │
│ 5. POST /api/spaces/{spaceId}/publish │
│ ↓ │
│ 6. Quorum API validates: │
│ - Verifies owner signature against registered owner key │
│ - Checks space exists (fetches manifest from hub) │
│ - Stores listing in directory database │
│ ↓ │
│ 7. Update local space.isPublic = true (if using for display) │
│ │
└──────────────────────────────────────────────────────────────────────┘
Browsing and Joining (Simplified Flow)
┌──────────────────────────────────────────────────────────────────────┐
│ BROWSE & JOIN FLOW │
├──────────────────────────────────────────────────────────────────────┤
│ │
│ 1. User navigates to /explore │
│ ↓ │
│ 2. GET /api/spaces/public → list of PublicSpaceListing │
│ (NO configKey returned - only public metadata!) │
│ ↓ │
│ 3. Display cards with: icon, name, description, member count │
│ ↓ │
│ 4. User clicks "Join" on a space card │
│ ↓ │
│ 5. Show loading state, GET /api/spaces/{id}/directory-invite │
│ ↓ │
│ 6. API returns the EXISTING public invite URL │
│ (same URL owner shares manually - fetched from space manifest) │
│ ↓ │
│ 7. Open JoinSpaceModal with invite link │
│ ↓ │
│ 8. Existing join flow handles: │
│ - Fetch manifest from /space/{id}/manifest │
│ - Consume one eval from /invite/eval │
│ - Set up encryption session │
│ - Register with hub │
│ - Save space locally │
│ │
│ KEY POINT: Uses the SAME invite pool as manual sharing. │
│ Directory is just a discovery layer on top of existing invites. │
│ │
└──────────────────────────────────────────────────────────────────────┘
Invite Regeneration Behavior
┌──────────────────────────────────────────────────────────────────────┐
│ INVITE REGENERATION │
├──────────────────────────────────────────────────────────────────────┤
│ │
│ When owner regenerates public invite link: │
│ │
│ 1. New configKey + new evals created (existing behavior) │
│ 2. space.inviteUrl updated with new URL │
│ 3. Old invite links stop working │
│ │
│ Directory behavior: │
│ - Directory-invite endpoint returns NEW URL automatically │
│ - No separate action needed by owner │
│ - Users who fetch invite AFTER regeneration get new URL │
│ - Users mid-join with old URL will fail (expected) │
│ │
│ Eval depletion: │
│ - If all ~200 evals consumed, joins fail with "No invites" │
│ - Owner must regenerate invite to create new pool │
│ - Consider: Show "invite exhausted" state in directory UI │
│ │
└──────────────────────────────────────────────────────────────────────┘
Open Questions
-
Moderation: How to handle inappropriate listings?✅ Resolved: Hybrid rating + report system with auto-moderation -
Categories/Tags: Should we predefine categories or allow freeform tags?✅ Resolved: Predefined categories (10 options) -
Member Count Accuracy: Real-time count or periodic update?
-
Listing Expiry: Should listings auto-expire if space becomes inactive?
-
Search Algorithm: Simple text match or weighted relevance?