diff --git a/app/admin/moderation/page.tsx b/app/admin/moderation/page.tsx new file mode 100644 index 0000000..166dfe2 --- /dev/null +++ b/app/admin/moderation/page.tsx @@ -0,0 +1,485 @@ +'use client'; + +import { useState } from 'react'; +import { useAccount } from 'wagmi'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { getApi, type ModerationReport, type ModerationState, type PenaltyType } from '@/lib/api'; +import { queryKeys } from '@/lib/query'; +import { AdminGuard } from '@/components/admin-guard'; +import { LoadingState, ErrorState, EmptyState, safeErrorMessage } from '@/components/ui/api-states'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { AddressText } from '@/components/wallet/address-text'; +import { + ShieldAlert, + Clock, + User, + AlertTriangle, + CheckCircle, + FileText, + Search, + ArrowRight, + UserMinus, + RefreshCw, + Scale +} from 'lucide-react'; +import Link from 'next/link'; + +export default function AdminModerationPage() { + const { address } = useAccount(); + const queryClient = useQueryClient(); + const [selectedReportId, setSelectedReportId] = useState(null); + const [statusFilter, setStatusFilter] = useState('all'); + const [searchQuery, setSearchQuery] = useState(''); + + // Action fields + const [adminNotes, setAdminNotes] = useState(''); + const [penaltyType, setPenaltyType] = useState('warning'); + const [appealNotes, setAppealNotes] = useState(''); + + // Query reports + const { + data: reports = [], + isLoading, + isError, + error, + refetch, + } = useQuery({ + queryKey: queryKeys.moderationReports.all, + queryFn: ({ signal }) => getApi(address).listReports(signal), + enabled: !!address, + }); + + // Mutation for updating report state + const updateReportMutation = useMutation({ + mutationFn: ({ + id, + state, + updates, + }: { + id: string; + state: ModerationState; + updates?: Partial; + }) => getApi(address).updateReportState(id, state, updates), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.moderationReports.all }); + // Reset action states + setAdminNotes(''); + setAppealNotes(''); + }, + }); + + const selectedReport = reports.find((r) => r.id === selectedReportId); + + const getStatusBadge = (state: ModerationState) => { + switch (state) { + case 'report_submitted': + return Submitted; + case 'under_review': + return Under Review; + case 'action_taken': + return Action Taken; + case 'dismissed': + return Dismissed; + case 'appeal_submitted': + return Appeal Pending; + case 'appeal_reviewed_reinstated': + return Reinstated; + case 'appeal_reviewed_upheld': + return Upheld; + default: + return {state}; + } + }; + + const filteredReports = reports.filter((r) => { + // Search filter + const matchesSearch = + r.reportedAddress.toLowerCase().includes(searchQuery.toLowerCase()) || + r.reporterAddress.toLowerCase().includes(searchQuery.toLowerCase()) || + r.reason.toLowerCase().includes(searchQuery.toLowerCase()); + + // Status filter + if (statusFilter === 'all') return matchesSearch; + if (statusFilter === 'active') { + return ( + matchesSearch && + ['report_submitted', 'under_review', 'appeal_submitted'].includes(r.state) + ); + } + if (statusFilter === 'closed') { + return ( + matchesSearch && + ['dismissed', 'action_taken', 'appeal_reviewed_reinstated', 'appeal_reviewed_upheld'].includes( + r.state + ) + ); + } + return matchesSearch && r.state === statusFilter; + }); + + return ( + +
+ {/* Header */} +
+
+

+ + Moderation Queue +

+

+ Review filed member reports, enforce code of conduct, and process reinstatement appeals. +

+
+ +
+ + {/* Dashboard Grid */} +
+ {/* Queue & Search Column */} +
+ + + Filed Reports + Select a report from the queue below to review details. + {/* Search and Filters */} +
+
+ + setSearchQuery(e.target.value)} + /> +
+
+ {(['all', 'active', 'closed'] as const).map((filter) => ( + + ))} +
+
+
+ + + {isLoading ? ( + + ) : isError ? ( + + ) : filteredReports.length === 0 ? ( + + ) : ( +
+ {filteredReports.map((report) => ( +
setSelectedReportId(report.id)} + className={`flex flex-col sm:flex-row sm:items-center justify-between p-4 cursor-pointer hover:bg-secondary/40 transition-colors border-l-4 ${ + selectedReportId === report.id + ? 'border-indigo-500 bg-secondary/20' + : report.state === 'report_submitted' + ? 'border-red-500' + : report.state === 'appeal_submitted' + ? 'border-blue-500' + : 'border-transparent' + }`} + > +
+
+ Report #{report.id} + {getStatusBadge(report.state)} +
+
+ + {new Date(report.createdAt).toLocaleString()} +
+

{report.reason}

+
+ + Reported: + +
+
+ +
+ ))} +
+ )} +
+
+
+ + {/* Details & Action Panel Column */} +
+ {selectedReport ? ( + + +
+ Report Details + {getStatusBadge(selectedReport.state)} +
+ ID: {selectedReport.id} +
+ + {/* Addresses */} +
+
+

Reporter

+
+ +
+
+
+

Reported Member

+
+ + + Profile + +
+
+
+ + {/* Incident */} +
+

+ + Reason & Incident Details +

+

{selectedReport.reason}

+ {selectedReport.details && ( +

+ {selectedReport.details} +

+ )} +
+ + {/* Penalty details if action taken */} + {selectedReport.penaltyApplied && ( +
+

+ Enforced Penalty +

+

{selectedReport.penaltyApplied}

+ {selectedReport.adminNotes && ( +

Notes: “{selectedReport.adminNotes}”

+ )} +
+ )} + + {/* Appeal Info */} + {selectedReport.appealNotes && ( +
+

+ Reinstatement Appeal +

+

+ “{selectedReport.appealNotes}” +

+
+ )} + + {/* Lifecycle State Actions */} +
+

+ + Workflow Actions +

+ + {/* State: report_submitted */} + {selectedReport.state === 'report_submitted' && ( + + )} + + {/* State: under_review */} + {selectedReport.state === 'under_review' && ( +
+
+ +
+ {(['warning', 'suspension', 'permanent_ban'] as PenaltyType[]).map((t) => ( + + ))} +
+
+ +
+ + setAdminNotes(e.target.value)} + /> +
+ +
+ + +
+
+ )} + + {/* State: action_taken (Enable testing/appeal demo) */} + {selectedReport.state === 'action_taken' && ( +
+

Demo / Simulate Member Appeal

+

+ To drive the report through the reinstatement lifecycle, simulate a member appealing this action. +

+ setAppealNotes(e.target.value)} + /> + +
+ )} + + {/* State: appeal_submitted */} + {selectedReport.state === 'appeal_submitted' && ( +
+
+ + setAdminNotes(e.target.value)} + /> +
+
+ + +
+
+ )} + + {/* Closed state placeholder */} + {['dismissed', 'appeal_reviewed_reinstated', 'appeal_reviewed_upheld'].includes( + selectedReport.state + ) && ( +
+ + This report has been resolved and closed. +
+ )} +
+
+
+ ) : ( + + + +

No Report Selected

+

Click any card on the left list to review report incident data and execute workflow actions.

+
+
+ )} +
+
+
+
+ ); +} diff --git a/app/members/[address]/page.tsx b/app/members/[address]/page.tsx index fa9a931..76e6518 100644 --- a/app/members/[address]/page.tsx +++ b/app/members/[address]/page.tsx @@ -1,7 +1,8 @@ 'use client'; import { useState } from 'react'; import { useParams } from 'next/navigation'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useAccount } from 'wagmi'; import Link from 'next/link'; import { getApi, type SocialLink } from '@/lib/api'; import { queryKeys } from '@/lib/query'; @@ -10,9 +11,22 @@ import { LoadingState, ErrorState, EmptyState, safeErrorMessage } from '@/compon import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { AddressText } from '@/components/wallet/address-text'; -import { buttonVariants } from '@/components/ui/button'; +import { Button, buttonVariants } from '@/components/ui/button'; import { features } from '@/lib/features'; import { isWalletAddress } from '@/lib/wallet/address'; +import { + UserPlus, + UserCheck, + UserMinus, + Ban, + Unlock, + Shield, + ShieldAlert, + Globe, + Lock, + Users, + EyeOff, +} from 'lucide-react'; function Avatar({ src, displayName }: { src?: string; displayName: string }) { const [failed, setFailed] = useState(false); @@ -22,7 +36,7 @@ function Avatar({ src, displayName }: { src?: string; displayName: string }) { return ( @@ -34,7 +48,7 @@ function Avatar({ src, displayName }: { src?: string; displayName: string }) { setFailed(true)} /> ); @@ -49,7 +63,7 @@ function SocialLinkList({ links }: { links: SocialLink[] }) { href={link.url} target="_blank" rel="noopener noreferrer" - className="text-sm text-primary underline-offset-4 hover:underline" + className="inline-flex items-center text-xs bg-secondary/50 text-secondary-foreground px-2.5 py-1 rounded-md border border-border hover:bg-secondary transition-all" > {link.platform} @@ -62,20 +76,132 @@ function SocialLinkList({ links }: { links: SocialLink[] }) { function MemberProfileView() { const { address } = useParams() as { address: string }; const addressValid = isWalletAddress(address); + const { address: viewerAddress } = useAccount(); + const queryClient = useQueryClient(); + const isOwner = viewerAddress?.toLowerCase() === address.toLowerCase(); + + // Queries const { data: profile, - isLoading, - isError, - error, - refetch, + isLoading: isProfileLoading, + isError: isProfileError, + error: profileError, + refetch: refetchProfile, } = useQuery({ queryKey: queryKeys.profile.byAddress(address), - queryFn: ({ signal }) => getApi().getProfile(address, signal), + queryFn: ({ signal }) => getApi(viewerAddress).getProfile(address, signal), enabled: addressValid, retry: 1, }); + const { + data: connections = [], + isLoading: isConnectionsLoading, + refetch: refetchConnections, + } = useQuery({ + queryKey: queryKeys.connections.byAddress(address), + queryFn: ({ signal }) => getApi(viewerAddress).getConnections(address, signal), + enabled: addressValid, + }); + + const { + data: viewerConnections = [], + } = useQuery({ + queryKey: queryKeys.connections.byAddress(viewerAddress || ''), + queryFn: ({ signal }) => getApi(viewerAddress).getConnections(viewerAddress!, signal), + enabled: !!viewerAddress, + }); + + const { + data: privacySettings, + isLoading: isPrivacyLoading, + refetch: refetchPrivacy, + } = useQuery({ + queryKey: queryKeys.privacySettings.byAddress(address), + queryFn: ({ signal }) => getApi(viewerAddress).getPrivacySettings(address, signal), + enabled: addressValid, + }); + + // Check if viewer has blocked target or target has blocked viewer + const viewerBlockedTarget = viewerConnections.some( + (c) => c.status === 'blocked' && c.toAddress.toLowerCase() === address.toLowerCase() + ); + const targetBlockedViewer = viewerConnections.some( + (c) => c.status === 'blocked' && c.fromAddress.toLowerCase() === address.toLowerCase() + ); + const isBlocked = viewerBlockedTarget || targetBlockedViewer; + + // Connection status between viewer and target + const connectionRecord = viewerConnections.find( + (c) => + c.status !== 'blocked' && + ((c.fromAddress.toLowerCase() === viewerAddress?.toLowerCase() && + c.toAddress.toLowerCase() === address.toLowerCase()) || + (c.toAddress.toLowerCase() === viewerAddress?.toLowerCase() && + c.fromAddress.toLowerCase() === address.toLowerCase())) + ); + + const connectionStatus = connectionRecord?.status; + const isIncoming = + connectionRecord?.toAddress.toLowerCase() === viewerAddress?.toLowerCase() && + connectionStatus === 'pending'; + const isOutgoing = + connectionRecord?.fromAddress.toLowerCase() === viewerAddress?.toLowerCase() && + connectionStatus === 'pending'; + + // Mutations + const connectMutation = useMutation({ + mutationFn: () => getApi(viewerAddress).createConnectionRequest(address), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.connections.byAddress(address) }); + queryClient.invalidateQueries({ queryKey: queryKeys.connections.byAddress(viewerAddress!) }); + }, + }); + + const acceptMutation = useMutation({ + mutationFn: () => getApi(viewerAddress).acceptConnectionRequest(address), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.connections.byAddress(address) }); + queryClient.invalidateQueries({ queryKey: queryKeys.connections.byAddress(viewerAddress!) }); + }, + }); + + const rejectMutation = useMutation({ + mutationFn: () => getApi(viewerAddress).rejectConnectionRequest(address), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.connections.byAddress(address) }); + queryClient.invalidateQueries({ queryKey: queryKeys.connections.byAddress(viewerAddress!) }); + }, + }); + + const blockMutation = useMutation({ + mutationFn: () => getApi(viewerAddress).blockMember(address), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.connections.byAddress(address) }); + queryClient.invalidateQueries({ queryKey: queryKeys.connections.byAddress(viewerAddress!) }); + }, + }); + + const unblockMutation = useMutation({ + mutationFn: () => getApi(viewerAddress).unblockMember(address), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.connections.byAddress(address) }); + queryClient.invalidateQueries({ queryKey: queryKeys.connections.byAddress(viewerAddress!) }); + }, + }); + + const updatePrivacyMutation = useMutation({ + mutationFn: (setting: 'public' | 'mutual-only' | 'private') => + getApi(viewerAddress).updatePrivacySettings(address, { + address, + connectionVisibility: setting, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.privacySettings.byAddress(address) }); + }, + }); + if (!addressValid) { return ( ; + if (isProfileLoading || isConnectionsLoading || isPrivacyLoading) { + return ; } - if (isError) { + if (isProfileError) { return ( refetch()} + message={safeErrorMessage(profileError)} + onRetry={() => { + refetchProfile(); + refetchConnections(); + refetchPrivacy(); + }} /> ); } @@ -109,54 +239,246 @@ function MemberProfileView() { } const displayName = profile.displayName?.trim() || 'Unnamed member'; + const privacySetting = privacySettings?.connectionVisibility || 'public'; + + // Determine connections viewability + const showConnections = + isOwner || + privacySetting === 'public' || + (privacySetting === 'mutual-only' && connectionStatus === 'accepted'); return ( -
- - - -
- {displayName} - -
-
- -
-

Bio

- {profile.bio ? ( -

{profile.bio}

- ) : ( -

This member hasn't added a bio yet.

+
+ {/* Block State Alert */} + {isBlocked && ( + + + +
+

Active Block in Effect

+

+ {viewerBlockedTarget + ? 'You have blocked this member. You must unblock them to see profile details and connect.' + : 'This member has blocked you. Profile details are hidden.'} +

+
+ {viewerBlockedTarget && ( + )} -
- -
-

Badges

- {profile.badges.length > 0 ? ( -
- {profile.badges.map((badge) => ( - {badge} - ))} + + + )} + + {/* Main Profile Info Card */} + {!targetBlockedViewer && ( + +
+ +
+ +
+ + {displayName} + {isOwner && You} + + +
+
+ + {/* Interaction Action Buttons */} + {viewerAddress && !isOwner && !isBlocked && ( +
+ {connectionStatus === 'accepted' ? ( + + ) : isOutgoing ? ( + + ) : isIncoming ? ( +
+ + +
+ ) : ( + + )} + + +
+ )} +
+ +
+

Bio

+ {profile.bio ? ( +

{profile.bio}

+ ) : ( +

This member hasn't added a bio yet.

+ )} +
+ +
+
+

Badges

+ {profile.badges.length > 0 ? ( +
+ {profile.badges.map((badge) => ( + + {badge} + + ))} +
+ ) : ( +

No badges yet.

+ )} +
+ +
+

Links

+ {profile.socialLinks && profile.socialLinks.length > 0 ? ( + + ) : ( +

No links shared yet.

+ )} +
+
+ + {/* Privacy Settings Control for Owner */} + {isOwner && ( +
+

+ + Connection Privacy Settings +

+

+ Control who can see your list of connections. +

+
+ {(['public', 'mutual-only', 'private'] as const).map((setting) => ( + + ))} +
- ) : ( -

No badges yet.

)} -
+ +
+ )} -
-

Links

- {profile.socialLinks && profile.socialLinks.length > 0 ? ( - + {/* Social Graph Connections List Card */} + {!targetBlockedViewer && ( + + + + + Connections + +
+ {privacySetting === 'public' && ( + <> + Publicly visible + + )} + {privacySetting === 'mutual-only' && ( + <> + Mutual connections only + + )} + {privacySetting === 'private' && ( + <> + Private + + )} +
+
+ + {showConnections ? ( + connections.length > 0 ? ( +
+ {connections.map((conn) => { + const otherAddress = + conn.fromAddress.toLowerCase() === address.toLowerCase() + ? conn.toAddress + : conn.fromAddress; + return ( +
+
+ + {otherAddress} + + + Status: {conn.status} + +
+ + View Profile + +
+ ); + })} +
+ ) : ( +

+ No connections yet. +

+ ) ) : ( -

No links shared yet.

+
+ +

Connections List Hidden

+

+ This member's connection list is restricted by their privacy settings. +

+
)} -
- - + + + )} - - Back to Dashboard - +
+ + Back to Dashboard + +
); } diff --git a/docs/social-graph-moderation.md b/docs/social-graph-moderation.md new file mode 100644 index 0000000..6ec998e --- /dev/null +++ b/docs/social-graph-moderation.md @@ -0,0 +1,144 @@ +# Social Graph & Moderation Protocol Layer — Specification + +**Status:** Proposed / Design Approved +**Feature Flag:** `NEXT_PUBLIC_FEATURE_PROFILES` (governs profile and social features), and admin moderation flag. +**Affected Files:** `lib/api/types.ts`, `app/members/[address]/page.tsx`, `app/admin/moderation/page.tsx` + +--- + +## 1. Social Graph Data Model + +The social graph handles relationships between community members using a mutual-connection model with bi-directional block capability. + +### 1.1 Connection Entity +A relationship between two member addresses is defined as a `Connection`: +- **Mutual Connection**: A connection is bi-directional and must be accepted by both parties. It transitions from a requested status to an accepted status. +- **Directional State (Legacy/Reference)**: While we use a mutual-connection flow, the database representation utilizes explicit directionality to track initiator vs receiver. + +```typescript +export type ConnectionStatus = 'pending' | 'accepted' | 'blocked'; + +export interface Connection { + id: string; + fromAddress: string; // Initiator of the connection or block + toAddress: string; // Target of the connection or block + status: ConnectionStatus; + createdAt: string; // ISO 8601 + updatedAt: string; // ISO 8601 +} +``` + +### 1.2 Block Entity +Blocks are represented as a special connection record with `status: 'blocked'`. +- When Member A blocks Member B, a record is written with `fromAddress: Member A`, `toAddress: Member B`, and `status: 'blocked'`. +- Any existing connection between Member A and Member B is deleted or overwritten by the block record. +- **Bi-directional Hiding**: The protocol layer and profile queries must filter out all data if a block exists in *either* direction (i.e. `(from = A AND to = B) OR (from = B AND to = A)`). + +--- + +## 2. Privacy Rules + +Connection visibility is governed by explicit privacy settings stored on each member's profile. + +### 2.1 Settings Option +A member can configure their connection list privacy via `ProfilePrivacySetting`: +- `public`: Anyone can view the connection list. +- `mutual-only`: Only other mutual connections of this profile can view the connection list. +- `private`: Only the profile owner can view the connection list. + +```typescript +export type PrivacySetting = 'public' | 'mutual-only' | 'private'; + +export interface MemberPrivacySettings { + address: string; + connectionVisibility: PrivacySetting; +} +``` + +### 2.2 Profile UI Visibility Logic +When Viewer V requests the profile of Member M: +1. **Block check**: If a block exists between Viewer V and Member M (in either direction), return `404 Not Found` (or equivalent error/empty state) for the profile. +2. **Profile data**: Return display name, avatar, bio, and badges. +3. **Connections list**: + - If Viewer V is Member M (owner): Show connection list. + - If Member M's setting is `public`: Show connection list. + - If Member M's setting is `mutual-only` AND Viewer V is a mutual connection (accepted state between M and V): Show connection list. + - Otherwise: Hide connection list and show a privacy indicator. + +--- + +## 3. Moderation State Machine + +The moderation queue handles member reports and appeals. It aligns with the backend workflow deferred in `guildpass-core`. + +### 3.1 Lifecycle States + +```mermaid +stateDiagram-v2 + [*] --> ReportSubmitted : Member files report + ReportSubmitted --> UnderReview : Admin assigns / starts review + UnderReview --> ActionTaken : Admin applies penalty (Suspension / Ban) + UnderReview --> Dismissed : Admin dismisses report + Dismissed --> [*] + + ActionTaken --> AppealSubmitted : Member submits appeal + AppealSubmitted --> AppealReviewed : Admin reviews appeal + AppealReviewed --> Reinstated : Appeal approved (revert action) + AppealReviewed --> Upheld : Appeal rejected (keep action) + + Reinstated --> [*] + Upheld --> [*] +``` + +### 3.2 Types Definition +```typescript +export type ModerationState = + | 'report_submitted' + | 'under_review' + | 'action_taken' + | 'dismissed' + | 'appeal_submitted' + | 'appeal_reviewed_reinstated' + | 'appeal_reviewed_upheld'; + +export type PenaltyType = 'warning' | 'suspension' | 'permanent_ban'; + +export interface ModerationReport { + id: string; + reporterAddress: string; + reportedAddress: string; + reason: string; + details?: string; + state: ModerationState; + penaltyApplied?: PenaltyType; + appealNotes?: string; + adminNotes?: string; + createdAt: string; + updatedAt: string; +} +``` + +--- + +## 4. Abuse-Case Review & Mitigations + +### 4.1 Harassment via Public Connection Lists +- **Threat**: Bad actors scrape connection lists to identify high-value targets, build social correlation maps, or harass a member's friends. +- **Mitigation**: Members can switch connection lists to `mutual-only` or `private`. Active blocks prevent the blocked user from obtaining the blocker's connection list, even if it is set to `public`. + +### 4.2 Harassment via Outbound/Incoming Requests +- **Threat**: Users receiving unwanted connection requests repeatedly from spam/harassment wallets. +- **Mitigation**: + 1. Once a member blocks another wallet, no requests can be initiated from the blocked wallet. + 2. Rate-limiting is applied to connection requests. + 3. Denying/ignoring a connection request moves it to a silent status rather than notifying the sender. + +### 4.3 Summary of Visibility Rules +| View Relationship | Block Active? | Connection Privacy | Connection List Visible? | +|-------------------|---------------|--------------------|--------------------------| +| Self | No | Any | Yes | +| Blocked (either) | Yes | Any | No (Profile Hidden) | +| Mutual Connection | No | mutual-only / pub | Yes | +| Non-Connection | No | public | Yes | +| Non-Connection | No | mutual-only | No | +| Anyone | No | private | No | diff --git a/lib/api/live.ts b/lib/api/live.ts index 3e62ecc..1cd2714 100644 --- a/lib/api/live.ts +++ b/lib/api/live.ts @@ -33,6 +33,13 @@ import { AccessPolicySchema, WebhookEventLogSchema, SiweAuthSessionSchema, + Connection, + ConnectionSchema, + MemberPrivacySettings, + MemberPrivacySettingsSchema, + ModerationReport, + ModerationReportSchema, + ModerationState, } from './types' import { mapCommunity, @@ -1011,4 +1018,84 @@ export class LiveAccessApi implements AccessApi { // best-effort logout }) } + + // ── Social Graph (Connections / Blocks) ── + async getConnections(address: string, signal?: AbortSignal): Promise { + const path = `/v1/members/${encodeURIComponent(address)}/connections` + return getJson(path, { signal, headers: this.authHeaders(), schema: z.array(ConnectionSchema) }) + } + + async getPrivacySettings(address: string, signal?: AbortSignal): Promise { + const path = `/v1/members/${encodeURIComponent(address)}/privacy` + return getJson(path, { signal, headers: this.authHeaders(), schema: MemberPrivacySettingsSchema }) + } + + async updatePrivacySettings(address: string, settings: MemberPrivacySettings): Promise { + const path = `/v1/members/${encodeURIComponent(address)}/privacy` + await getJson(path, { + method: 'PUT', + body: JSON.stringify(settings), + headers: this.authHeaders(), + }) + } + + async blockMember(targetAddress: string): Promise { + const path = `/v1/members/${encodeURIComponent(targetAddress)}/block` + await getJson(path, { + method: 'POST', + headers: this.authHeaders(), + }) + } + + async unblockMember(targetAddress: string): Promise { + const path = `/v1/members/${encodeURIComponent(targetAddress)}/block` + await getJson(path, { + method: 'DELETE', + headers: this.authHeaders(), + }) + } + + async createConnectionRequest(targetAddress: string): Promise { + const path = `/v1/members/${encodeURIComponent(targetAddress)}/connections` + await getJson(path, { + method: 'POST', + headers: this.authHeaders(), + }) + } + + async acceptConnectionRequest(targetAddress: string): Promise { + const path = `/v1/members/${encodeURIComponent(targetAddress)}/connections/accept` + await getJson(path, { + method: 'POST', + headers: this.authHeaders(), + }) + } + + async rejectConnectionRequest(targetAddress: string): Promise { + const path = `/v1/members/${encodeURIComponent(targetAddress)}/connections/reject` + await getJson(path, { + method: 'POST', + headers: this.authHeaders(), + }) + } + + // ── Moderation Queue ── + async listReports(signal?: AbortSignal): Promise { + const path = `/v1/admin/reports` + return getJson(path, { signal, headers: this.authHeaders(), schema: z.array(ModerationReportSchema) }) + } + + async getReport(id: string, signal?: AbortSignal): Promise { + const path = `/v1/admin/reports/${encodeURIComponent(id)}` + return getJson(path, { signal, headers: this.authHeaders(), schema: ModerationReportSchema.nullable() }) + } + + async updateReportState(id: string, state: ModerationState, updates?: Partial): Promise { + const path = `/v1/admin/reports/${encodeURIComponent(id)}/state` + await getJson(path, { + method: 'PUT', + body: JSON.stringify({ state, ...updates }), + headers: this.authHeaders(), + }) + } } \ No newline at end of file diff --git a/lib/api/mock.ts b/lib/api/mock.ts index 63eb27b..d44fe62 100644 --- a/lib/api/mock.ts +++ b/lib/api/mock.ts @@ -44,6 +44,13 @@ import { WalletVerification, WebhookEventLog, WebhookEventUnsubscribe, + Connection, + MemberPrivacySettings, + ModerationReport, + ModerationState, + AdminEventFilterParams, + Paginated, + WebhookEvent, } from './types' import { ApiError } from './errors' import { @@ -342,6 +349,40 @@ const MOCK_MEMBER_STORES: Record = {}; + +export let mockReports: ModerationReport[] = [ + { + id: 'rep-1', + reporterAddress: '0x3333333333333333333333333333333333333333', + reportedAddress: '0x1234567890123456789012345678901234567890', + reason: 'Spamming connection requests', + details: 'Received 15 pending requests in 10 minutes.', + state: 'report_submitted', + createdAt: new Date(Date.now() - 1000 * 60 * 60 * 2).toISOString(), + updatedAt: new Date(Date.now() - 1000 * 60 * 60 * 2).toISOString() + } +]; + export interface CommunityState { community: Community resources: Resource[] @@ -1087,7 +1128,7 @@ export class MockAccessApi implements AccessApi { } async listAdminEvents(params?: AdminEventFilterParams): Promise> { - let events = mockWebhookEvents + let events = getCommunityState(this.communityId).webhookEvents as any[] if (params?.types && params.types.length > 0) { events = events.filter((e) => params.types!.includes(e.type)) @@ -1215,5 +1256,157 @@ export class MockAccessApi implements AccessApi { checkedAt: new Date().toISOString(), } } + + // ── Social Graph (Connections / Blocks) ── + async getConnections(address: string, _signal?: AbortSignal): Promise { + await initPromise + const addr = address.toLowerCase() + const viewer = this.address?.toLowerCase() + + // 1. Block check: active block in either direction -> empty/hidden profile + const isBlocked = mockConnections.some(c => + c.status === 'blocked' && + ((c.fromAddress.toLowerCase() === viewer && c.toAddress.toLowerCase() === addr) || + (c.toAddress.toLowerCase() === viewer && c.fromAddress.toLowerCase() === addr)) + ) + if (isBlocked) { + return [] + } + + // 2. Privacy rules check + const targetPrivacy = mockPrivacySettings[addr]?.connectionVisibility || 'public' + const isOwner = viewer === addr + if (!isOwner) { + if (targetPrivacy === 'private') { + return [] + } + if (targetPrivacy === 'mutual-only') { + const hasMutual = mockConnections.some(c => + c.status === 'accepted' && + ((c.fromAddress.toLowerCase() === viewer && c.toAddress.toLowerCase() === addr) || + (c.toAddress.toLowerCase() === viewer && c.fromAddress.toLowerCase() === addr)) + ) + if (!hasMutual) return [] + } + } + + // Return non-blocked connections for this address + return mockConnections.filter(c => + c.status !== 'blocked' && + (c.fromAddress.toLowerCase() === addr || c.toAddress.toLowerCase() === addr) + ) + } + + async getPrivacySettings(address: string, _signal?: AbortSignal): Promise { + await initPromise + const addr = address.toLowerCase() + return mockPrivacySettings[addr] || { address, connectionVisibility: 'public' } + } + + async updatePrivacySettings(address: string, settings: MemberPrivacySettings): Promise { + await initPromise + const addr = address.toLowerCase() + mockPrivacySettings[addr] = settings + } + + async blockMember(targetAddress: string): Promise { + await initPromise + if (!this.address) throw new Error('Not logged in') + const viewer = this.address.toLowerCase() + const target = targetAddress.toLowerCase() + + // Remove existing connections between them + mockConnections = mockConnections.filter(c => + !((c.fromAddress.toLowerCase() === viewer && c.toAddress.toLowerCase() === target) || + (c.toAddress.toLowerCase() === viewer && c.fromAddress.toLowerCase() === target)) + ) + + // Add block record + mockConnections.push({ + id: `block-${Date.now()}`, + fromAddress: this.address, + toAddress: targetAddress, + status: 'blocked', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }) + } + + async unblockMember(targetAddress: string): Promise { + await initPromise + if (!this.address) throw new Error('Not logged in') + const viewer = this.address.toLowerCase() + const target = targetAddress.toLowerCase() + + mockConnections = mockConnections.filter(c => + !(c.status === 'blocked' && c.fromAddress.toLowerCase() === viewer && c.toAddress.toLowerCase() === target) + ) + } + + async createConnectionRequest(targetAddress: string): Promise { + await initPromise + if (!this.address) throw new Error('Not logged in') + mockConnections.push({ + id: `conn-${Date.now()}`, + fromAddress: this.address, + toAddress: targetAddress, + status: 'pending', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }) + } + + async acceptConnectionRequest(targetAddress: string): Promise { + await initPromise + if (!this.address) throw new Error('Not logged in') + const viewer = this.address.toLowerCase() + const target = targetAddress.toLowerCase() + + const conn = mockConnections.find(c => + c.status === 'pending' && + c.fromAddress.toLowerCase() === target && + c.toAddress.toLowerCase() === viewer + ) + if (conn) { + conn.status = 'accepted' + conn.updatedAt = new Date().toISOString() + } + } + + async rejectConnectionRequest(targetAddress: string): Promise { + await initPromise + if (!this.address) throw new Error('Not logged in') + const viewer = this.address.toLowerCase() + const target = targetAddress.toLowerCase() + + mockConnections = mockConnections.filter(c => + !(c.status === 'pending' && + c.fromAddress.toLowerCase() === target && + c.toAddress.toLowerCase() === viewer) + ) + } + + // ── Moderation Queue ── + async listReports(_signal?: AbortSignal): Promise { + await initPromise + return mockReports + } + + async getReport(id: string, _signal?: AbortSignal): Promise { + await initPromise + return mockReports.find(r => r.id === id) || null + } + + async updateReportState(id: string, state: ModerationState, updates?: Partial): Promise { + await initPromise + const report = mockReports.find(r => r.id === id) + if (report) { + report.state = state + if (updates) { + Object.assign(report, updates) + } + report.updatedAt = new Date().toISOString() + } + } } diff --git a/lib/api/types.ts b/lib/api/types.ts index 4e78eb6..847bac9 100644 --- a/lib/api/types.ts +++ b/lib/api/types.ts @@ -8,6 +8,11 @@ import { z } from 'zod'; import { ApiError } from './errors' +export type ResourceLookupResult = + | { status: 'found'; data: Resource; source: 'direct' | 'fallback' } + | { status: 'not_found' } + | { status: 'error'; error: ApiError } + export type Role = 'member' | 'moderator' | 'admin' export const RoleSchema = z.enum(['member', 'moderator', 'admin']) @@ -34,10 +39,6 @@ export const WebhookEventLogSchema = z.object({ timestamp: z.string(), affectedIdentifier: z.string(), payloadSummary: WebhookPayloadSummarySchema, - /** Raw event payload for detail inspection (optional — added by the replay/debug tool). */ - fullPayload: z.record(z.unknown()).optional(), - /** True when this entry was injected via the replay/debug tool rather than ingested from a real webhook. */ - isReplay: z.boolean().optional(), }) export interface Community { @@ -82,7 +83,6 @@ export interface MemberProfile { address: string displayName?: string bio?: string - /** URL of the member's avatar image. */ avatar?: string socialLinks?: SocialLink[] badges: string[] @@ -138,12 +138,6 @@ export interface Resource { content?: ResourceContentBlock[] } -export type ResourceLookupResult = - | { status: 'found'; data: Resource; source: 'direct' | 'fallback' } - | { status: 'not_found' } - | { status: 'error'; error: ApiError } - - export const ResourceSchema = z.object({ id: z.string(), title: z.string(), @@ -175,7 +169,6 @@ export interface AccessPolicy { minTier?: MembershipTier roles?: Role[] rule?: AccessRule - /** ISO 8601 timestamp of the last policy update. Used for optimistic concurrency control. */ updatedAt?: string } @@ -201,6 +194,48 @@ export const MemberRowSchema = z.object({ active: z.boolean(), }) +export interface MemberGrowthDataPoint { + date: string + newMembers: number + totalMembers: number +} + +export const MemberGrowthDataPointSchema = z.object({ + date: z.string(), + newMembers: z.number(), + totalMembers: z.number(), +}) + +export interface ResourceAccessCount { + resourceId: string + resourceTitle: string + accessCount: number + deniedCount: number +} + +export const ResourceAccessCountSchema = z.object({ + resourceId: z.string(), + resourceTitle: z.string(), + accessCount: z.number(), + deniedCount: z.number(), +}) + +export interface AnalyticsSummary { + totalMembers: number + activeMembers: number + memberGrowth: MemberGrowthDataPoint[] + resourceAccess: ResourceAccessCount[] + generatedAt: string +} + +export const AnalyticsSummarySchema = z.object({ + totalMembers: z.number(), + activeMembers: z.number(), + memberGrowth: z.array(MemberGrowthDataPointSchema), + resourceAccess: z.array(ResourceAccessCountSchema), + generatedAt: z.string(), +}) + export const ApiErrorBodySchema = z.object({ code: z.string().optional(), error: z.string().optional(), @@ -210,19 +245,10 @@ export const ApiErrorBodySchema = z.object({ export interface SiweAuthSession { isAuthenticated: true - /** Short-lived access token (typically 1 h). Attach as `Authorization: Bearer` on admin mutations. */ token: string address: string - /** ISO 8601 expiry of the access token. */ expiresAt: string - /** - * Opaque longer-lived refresh credential (typically 7 d). - * Must be treated as a secret — never log or expose it. - * Optional so that existing persisted sessions without a refresh token - * are still valid (they will just not support silent renewal). - */ refreshToken?: string - /** ISO 8601 expiry of the refresh token. Absence means no refresh is available. */ refreshExpiresAt?: string } @@ -241,6 +267,78 @@ export const WalletVerificationSchema = z.object({ checkedAt: z.string(), }) +export type ConnectionStatus = 'pending' | 'accepted' | 'blocked' + +export const ConnectionStatusSchema = z.enum(['pending', 'accepted', 'blocked']) + +export interface Connection { + id: string + fromAddress: string + toAddress: string + status: ConnectionStatus + createdAt: string + updatedAt: string +} + +export const ConnectionSchema = z.object({ + id: z.string(), + fromAddress: z.string(), + toAddress: z.string(), + status: ConnectionStatusSchema, + createdAt: z.string(), + updatedAt: z.string(), +}) + +export type PrivacySetting = 'public' | 'mutual-only' | 'private' + +export const PrivacySettingSchema = z.enum(['public', 'mutual-only', 'private']) + +export interface MemberPrivacySettings { + address: string + connectionVisibility: PrivacySetting +} + +export const MemberPrivacySettingsSchema = z.object({ + address: z.string(), + connectionVisibility: PrivacySettingSchema, +}) + +export type ModerationState = 'report_submitted' | 'under_review' | 'action_taken' | 'dismissed' | 'appeal_submitted' | 'appeal_reviewed_reinstated' | 'appeal_reviewed_upheld' + +export const ModerationStateSchema = z.enum(['report_submitted', 'under_review', 'action_taken', 'dismissed', 'appeal_submitted', 'appeal_reviewed_reinstated', 'appeal_reviewed_upheld']) + +export type PenaltyType = 'warning' | 'suspension' | 'permanent_ban' + +export const PenaltyTypeSchema = z.enum(['warning', 'suspension', 'permanent_ban']) + +export interface ModerationReport { + id: string + reporterAddress: string + reportedAddress: string + reason: string + details?: string + state: ModerationState + penaltyApplied?: PenaltyType + appealNotes?: string + adminNotes?: string + createdAt: string + updatedAt: string +} + +export const ModerationReportSchema = z.object({ + id: z.string(), + reporterAddress: z.string(), + reportedAddress: z.string(), + reason: z.string(), + details: z.string().optional(), + state: ModerationStateSchema, + penaltyApplied: PenaltyTypeSchema.optional(), + appealNotes: z.string().optional(), + adminNotes: z.string().optional(), + createdAt: z.string(), + updatedAt: z.string(), +}) + export type WebhookEventStatus = 'success' | 'failed' | 'pending'; export type WebhookEventType = @@ -300,12 +398,6 @@ export interface MemberGrowthDataPoint { totalMembers: number } -export const MemberGrowthDataPointSchema = z.object({ - date: z.string(), - newMembers: z.number().int().nonnegative(), - totalMembers: z.number().int().nonnegative(), -}) - /** * Access attempt counts for a single gated resource. */ @@ -318,13 +410,6 @@ export interface ResourceAccessCount { deniedCount: number } -export const ResourceAccessCountSchema = z.object({ - resourceId: z.string(), - resourceTitle: z.string(), - accessCount: z.number().int().nonnegative(), - deniedCount: z.number().int().nonnegative(), -}) - /** * Top-level analytics summary for the admin dashboard. * @@ -346,14 +431,6 @@ export interface AnalyticsSummary { generatedAt: string } -export const AnalyticsSummarySchema = z.object({ - totalMembers: z.number().int().nonnegative(), - activeMembers: z.number().int().nonnegative(), - memberGrowth: z.array(MemberGrowthDataPointSchema), - resourceAccess: z.array(ResourceAccessCountSchema), - generatedAt: z.string(), -}) - // ── Access Decision (cached per wallet + resource) ─────────────────────────── /** @@ -513,6 +590,16 @@ export interface MemberAccessApi { * not settable through this method. */ updateProfile(profile: MemberProfile): Promise + + // ── Social Graph (Connections / Blocks) ── + getConnections(address: string, signal?: AbortSignal): Promise + getPrivacySettings(address: string, signal?: AbortSignal): Promise + updatePrivacySettings(address: string, settings: MemberPrivacySettings): Promise + blockMember(targetAddress: string): Promise + unblockMember(targetAddress: string): Promise + createConnectionRequest(targetAddress: string): Promise + acceptConnectionRequest(targetAddress: string): Promise + rejectConnectionRequest(targetAddress: string): Promise } /** @@ -543,6 +630,11 @@ export interface AdminAccessApi { assignRole(address: string, role: Role): Promise removeRole(address: string, role: Role): Promise updatePolicy(policy: AccessPolicy): Promise + + // ── Moderation Queue ── + listReports(signal?: AbortSignal): Promise + getReport(id: string, signal?: AbortSignal): Promise + updateReportState(id: string, state: ModerationState, updates?: Partial): Promise } /** diff --git a/lib/query/query-keys.ts b/lib/query/query-keys.ts index 2d8da48..52aca96 100644 --- a/lib/query/query-keys.ts +++ b/lib/query/query-keys.ts @@ -95,4 +95,20 @@ export const queryKeys = { invoices: (address: string, community: string = 'guildpass-demo') => ['billing', 'invoices', address, community] as const, }, + + // Connections + connections: { + byAddress: (address: string) => ['connections', address] as const, + }, + + // Privacy Settings + privacySettings: { + byAddress: (address: string) => ['privacySettings', address] as const, + }, + + // Moderation Reports + moderationReports: { + all: ['moderationReports'] as const, + detail: (id: string) => ['moderationReport', id] as const, + }, } diff --git a/scripts/sync-api-types.js b/scripts/sync-api-types.js index c01a9e2..c6a3e92 100644 --- a/scripts/sync-api-types.js +++ b/scripts/sync-api-types.js @@ -14,6 +14,8 @@ export type WebhookEventType = | 'tier.upgraded' | 'policy.updated'; +export type WebhookEventUnsubscribe = () => void + export interface WebhookEventLog { id: string; eventType: WebhookEventType; @@ -26,6 +28,10 @@ export interface WebhookEventLog { tier?: string; reason?: string; }; + /** Raw event payload for detail inspection (optional — added by the replay/debug tool). */ + fullPayload?: Record; + /** True when this entry was injected via the replay/debug tool rather than ingested from a real webhook. */ + isReplay?: boolean; } export interface WalletVerification { @@ -41,6 +47,56 @@ export interface ApiErrorBody { details?: Record } +// ── Analytics Types ────────────────────────────────────────────────────────── +// NOTE: The analytics endpoint is PROVISIONAL. The path /v1/admin/analytics +// has not yet been confirmed by guildpass-core. This contract is documented +// here so the frontend and backend can align. Tracked in issue #157. + +/** + * A single data point in the member growth time series. + */ +export interface MemberGrowthDataPoint { + /** ISO 8601 date (YYYY-MM-DD) representing the start of the interval. */ + date: string + /** Number of new members who joined during this interval. */ + newMembers: number + /** Cumulative total member count at end of interval. */ + totalMembers: number +} + +/** + * Access attempt counts for a single gated resource. + */ +export interface ResourceAccessCount { + resourceId: string + resourceTitle: string + /** Total number of access attempts for this resource. */ + accessCount: number + /** Number of denied access attempts (insufficient tier/role). */ + deniedCount: number +} + +/** + * Top-level analytics summary for the admin dashboard. + * + * @provisional Endpoint \`/v1/admin/analytics\` is not yet implemented in + * guildpass-core. This type definition captures the proposed contract so + * frontend and backend can align. The mock implementation uses seeded data; + * the live implementation will use this schema once the backend ships. + */ +export interface AnalyticsSummary { + /** Total community member count. */ + totalMembers: number + /** Count of members with an active membership. */ + activeMembers: number + /** Member growth time series (most recent 30 days, daily intervals). */ + memberGrowth: MemberGrowthDataPoint[] + /** Per-resource access and denial counts. */ + resourceAccess: ResourceAccessCount[] + /** ISO timestamp when this summary was generated. */ + generatedAt: string +} + // ── Access Decision (cached per wallet + resource) ─────────────────────────── /** @@ -57,6 +113,29 @@ export interface AccessDecision { checkedAt: string } +export interface WebhookEvent { + id: string + type: string + payload: any + createdAt: string + status?: string +} + +export interface Paginated { + data: T[] + total: number + page: number + limit: number +} + +export interface AdminEventFilterParams { + types?: string[] + startDate?: string + endDate?: string + page?: number + limit?: number +} + // ── Client-side State Types ────────────────────────────────────────────────── /** @@ -103,6 +182,9 @@ export interface BackendMember { display_name?: string username?: string bio?: string + avatar?: string + socialLinks?: SocialLink[] + social_links?: SocialLink[] badges?: string[] } @@ -124,6 +206,8 @@ export interface BackendPolicy { min_tier?: MembershipTier roles?: Role[] rule?: AccessRule + updatedAt?: string + updated_at?: string } export interface BackendSession { @@ -145,22 +229,43 @@ export interface BackendSession { * Read-only member and resource queries. * No SIWE token is required for these operations. */ +export interface PaginatedMembers { + members: MemberRow[] + nextCursor?: string +} + export interface MemberAccessApi { // ── Read-only (no auth token required) ────────────────────────────────── - getSession(): Promise - getCommunity(): Promise - getMembership(address: string): Promise - verifyWallet(address: string): Promise - getProfile(address: string): Promise - listMembers(): Promise - listResources(): Promise - listPolicies(): Promise - getResource(id: string): Promise - getPolicy(resourceId: string): Promise - // NOTE: ownership/auth enforcement for this mutation is not yet decided - // (tracked with the #254 profile-customization work) — placed here to - // match the read-only getProfile() it pairs with, pending that decision. + getSession(signal?: AbortSignal): Promise + getCommunity(signal?: AbortSignal): Promise + getMembership(address: string, signal?: AbortSignal): Promise + verifyWallet(address: string, signal?: AbortSignal): Promise + getProfile(address: string, signal?: AbortSignal): Promise + listMembers(params?: { cursor?: string; limit?: number; filter?: string }, signal?: AbortSignal): Promise + listResources(signal?: AbortSignal): Promise + listPolicies(signal?: AbortSignal): Promise + getResource(id: string, signal?: AbortSignal): Promise + getPolicy(resourceId: string, signal?: AbortSignal): Promise + /** + * Updates the caller's own profile (display name, bio, avatar, social + * links). The one mutation on this interface: unlike the rest of + * {@link MemberAccessApi} it requires a SIWE bearer token, but reuses the + * existing member/admin SIWE session rather than a separate auth + * mechanism — the backend must reject the request unless the token's + * address matches \`profile.address\`. \`badges\` is system-assigned and is + * not settable through this method. + */ updateProfile(profile: MemberProfile): Promise + + // ── Social Graph (Connections / Blocks) ── + getConnections(address: string, signal?: AbortSignal): Promise + getPrivacySettings(address: string, signal?: AbortSignal): Promise + updatePrivacySettings(address: string, settings: MemberPrivacySettings): Promise + blockMember(targetAddress: string): Promise + unblockMember(targetAddress: string): Promise + createConnectionRequest(targetAddress: string): Promise + acceptConnectionRequest(targetAddress: string): Promise + rejectConnectionRequest(targetAddress: string): Promise } /** @@ -169,10 +274,33 @@ export interface MemberAccessApi { */ export interface AdminAccessApi { // ── Admin queries & mutations (require a valid SIWE token context) ──────── - listWebhookEvents(): Promise + listWebhookEvents(signal?: AbortSignal): Promise + /** + * Subscribe to the admin webhook event stream. + * + * @provisional Live mode attempts \`GET /v1/admin/events/stream\` as an + * SSE-compatible stream. If setup fails, the caller should fall back to + * \`listWebhookEvents()\` polling. + */ + subscribeWebhookEvents( + onEvent: (event: WebhookEventLog) => void, + onError?: (error: unknown) => void, + ): WebhookEventUnsubscribe + /** + * Fetch the analytics summary for the admin dashboard. + * + * @provisional Calls \`GET /v1/admin/analytics\` — endpoint not yet live in + * guildpass-core. Contract tracked in issue #157; pending backend confirmation. + */ + getAnalyticsSummary(signal?: AbortSignal): Promise assignRole(address: string, role: Role): Promise removeRole(address: string, role: Role): Promise updatePolicy(policy: AccessPolicy): Promise + + // ── Moderation Queue ── + listReports(signal?: AbortSignal): Promise + getReport(id: string, signal?: AbortSignal): Promise + updateReportState(id: string, state: ModerationState, updates?: Partial): Promise } /** @@ -184,9 +312,19 @@ export interface SiweAuthApi { getNonce(address: string): Promise /** * Submit a signed EIP-4361 message and receive an authenticated session - * token. The backend verifies the signature and returns a short-lived token. + * token. The backend verifies the signature and returns a short-lived access + * token plus a longer-lived refresh token. */ siweVerify(message: string, signature: string): Promise + /** + * Exchange a valid refresh token for a fresh access token (and a new + * refresh token — token rotation). The caller must immediately persist the + * returned session and discard the old refresh token. + * + * Throws a 401 ApiError when the refresh token is expired or invalid, + * signalling that the user must re-sign with their wallet. + */ + siweRefresh(refreshToken: string): Promise /** Invalidate the current server-side session (no-op for stateless JWTs). */ siweLogout(token: string): Promise verifyWallet(address: string): Promise @@ -316,6 +454,12 @@ function generateTypes(schema) { */ import { z } from 'zod'; +import { ApiError } from './errors' + +export type ResourceLookupResult = + | { status: 'found'; data: Resource; source: 'direct' | 'fallback' } + | { status: 'not_found' } + | { status: 'error'; error: ApiError } `; diff --git a/test/fixtures/openapi.json b/test/fixtures/openapi.json index 3605ab1..4d3028a 100644 --- a/test/fixtures/openapi.json +++ b/test/fixtures/openapi.json @@ -375,7 +375,8 @@ "type": "array", "items": { "$ref": "#/components/schemas/Role" } }, - "rule": { "$ref": "#/components/schemas/AccessRule" } + "rule": { "$ref": "#/components/schemas/AccessRule" }, + "updatedAt": { "type": "string", "format": "date-time" } }, "required": ["resourceId"] }, @@ -477,7 +478,9 @@ "isAuthenticated": { "type": "boolean", "enum": [true] }, "token": { "type": "string" }, "address": { "type": "string" }, - "expiresAt": { "type": "string", "format": "date-time" } + "expiresAt": { "type": "string", "format": "date-time" }, + "refreshToken": { "type": "string" }, + "refreshExpiresAt": { "type": "string", "format": "date-time" } }, "required": ["isAuthenticated", "token", "address", "expiresAt"] }, @@ -489,6 +492,67 @@ "checkedAt": { "type": "string", "format": "date-time" } }, "required": ["verified", "checkedAt"] + }, + "ConnectionStatus": { + "type": "string", + "enum": ["pending", "accepted", "blocked"] + }, + "Connection": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "fromAddress": { "type": "string" }, + "toAddress": { "type": "string" }, + "status": { "$ref": "#/components/schemas/ConnectionStatus" }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" } + }, + "required": ["id", "fromAddress", "toAddress", "status", "createdAt", "updatedAt"] + }, + "PrivacySetting": { + "type": "string", + "enum": ["public", "mutual-only", "private"] + }, + "MemberPrivacySettings": { + "type": "object", + "properties": { + "address": { "type": "string" }, + "connectionVisibility": { "$ref": "#/components/schemas/PrivacySetting" } + }, + "required": ["address", "connectionVisibility"] + }, + "ModerationState": { + "type": "string", + "enum": [ + "report_submitted", + "under_review", + "action_taken", + "dismissed", + "appeal_submitted", + "appeal_reviewed_reinstated", + "appeal_reviewed_upheld" + ] + }, + "PenaltyType": { + "type": "string", + "enum": ["warning", "suspension", "permanent_ban"] + }, + "ModerationReport": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "reporterAddress": { "type": "string" }, + "reportedAddress": { "type": "string" }, + "reason": { "type": "string" }, + "details": { "type": "string" }, + "state": { "$ref": "#/components/schemas/ModerationState" }, + "penaltyApplied": { "$ref": "#/components/schemas/PenaltyType" }, + "appealNotes": { "type": "string" }, + "adminNotes": { "type": "string" }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" } + }, + "required": ["id", "reporterAddress", "reportedAddress", "reason", "state", "createdAt", "updatedAt"] } } }