From 3b935ebfa3d4c87d175a9afedf21f250d1d51db0 Mon Sep 17 00:00:00 2001 From: vijay11149 Date: Thu, 20 Aug 2026 00:55:22 +0000 Subject: [PATCH] refactor: split oversized mock API into focused domain modules (Closes #383) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Organize the ~1,800-line lib/api/mock.ts into focused modules under lib/api/mock/ (state, session, core, members, analytics, webhooks, approvals, social, moderation, governance, controls, scenarios) and turn mock.ts into a thin aggregator that composes MockAccessApi and re-exports every historical symbol, so no consumer imports change. Also fix compile errors that lived in this file: add 'invalid_state' to ApiErrorCode (used by governance methods), complete the legacy persisted-state load with proposals/votes, and route social-store reassignment through owning-module setters (fixes a latent blockMember runtime crash under ESM/CJS module semantics). πŸ€– Generated with Codebuff Co-Authored-By: Codebuff --- docs/mock-api-boundaries.md | 73 +- lib/api/errors.ts | 1 + lib/api/mock.ts | 1793 ++++------------------------------- lib/api/mock/analytics.ts | 80 ++ lib/api/mock/approvals.ts | 195 ++++ lib/api/mock/controls.ts | 116 +++ lib/api/mock/core.ts | 93 ++ lib/api/mock/fixtures.ts | 24 + lib/api/mock/governance.ts | 414 ++++++++ lib/api/mock/members.ts | 122 +++ lib/api/mock/moderation.ts | 35 + lib/api/mock/scenarios.ts | 243 +++++ lib/api/mock/session.ts | 259 +++++ lib/api/mock/social.ts | 153 +++ lib/api/mock/state.ts | 184 ++++ lib/api/mock/webhooks.ts | 177 ++++ 16 files changed, 2332 insertions(+), 1630 deletions(-) create mode 100644 lib/api/mock/analytics.ts create mode 100644 lib/api/mock/approvals.ts create mode 100644 lib/api/mock/controls.ts create mode 100644 lib/api/mock/core.ts create mode 100644 lib/api/mock/governance.ts create mode 100644 lib/api/mock/members.ts create mode 100644 lib/api/mock/moderation.ts create mode 100644 lib/api/mock/scenarios.ts create mode 100644 lib/api/mock/session.ts create mode 100644 lib/api/mock/social.ts create mode 100644 lib/api/mock/state.ts create mode 100644 lib/api/mock/webhooks.ts diff --git a/docs/mock-api-boundaries.md b/docs/mock-api-boundaries.md index 6cc2e45..48297b1 100644 --- a/docs/mock-api-boundaries.md +++ b/docs/mock-api-boundaries.md @@ -44,39 +44,41 @@ All application code (components, pages, tests) must import from `@/lib/api`. Ne --- -### `lib/api/mock.ts` β€” Implementation: Fixtures + Behavior - -**Responsibility:** Core mock API implementation split into two concerns: - -#### Fixtures (Static Data) -These are default/seeded data sets that populate the mock store: -- `DEFAULT_COMMUNITY` β€” Demo community configuration -- `DEFAULT_RESOURCES` β€” Gated resource catalog (alpha, pro-reports, mem-updates) -- `DEFAULT_POLICIES` β€” Access policies including composable rule examples -- `DEFAULT_WEBHOOK_EVENTS` β€” Example webhook event log entries -- `DEFAULT_MEMBER_STORE` β€” 50,000 synthetic seeded members with realistic names, tiers, roles - -Fixtures initialize the mock state and are restored by `resetMockData()`. They are never directly imported by application code; they live in `mock.ts` to keep implementation details private. - -#### Behavior (Simulation Logic) -These functions implement mock API behaviors: -- **SIWE simulation:** `getNonce()`, `siweVerify()`, `siweLogout()` (no real cryptography) -- **Session state:** Mock cookie reading/writing for `cookie` auth mode -- **Scenario presets:** Apply scripted test scenarios (active member, expired member, denied access, etc.) -- **Event replay:** Replay webhook events (e.g., for testing event-driven UI updates) -- **Failure injection:** `setMockRoleMutationFailure()`, `setMockResourceFetchFailure()`, `setMockResourceFetchDelay()` for failure mode testing -- **Version override:** `setMockMetaVersion()` for API compatibility testing - -**MockAccessApi class:** Implements the `AccessApi` interface, providing: -- Read methods: `getSession()`, `getCommunity()`, `getMembers()`, `getMembership()`, etc. +### `lib/api/mock.ts` β€” Implementation: Composition & Re-exports + +**Responsibility:** Thin aggregation point. `MockAccessApi` composes focused, domain-scoped modules under `lib/api/mock/`, and every historical public export (`MockAccessApi`, `getCommunityState()`, `communityStates`, developer controls, etc.) is re-exported so existing imports keep working. + +The implementation is deliberately split so a structural mistake in one domain cannot break the whole API layer: + +| Module | Responsibility | +| --- | --- | +| `mock/fixtures.ts` | Fixture/seeded data (communities, members, policies, webhook events, connections, reports) + the mutable social/moderation stores | +| `mock/state.ts` | In-memory per-community store (`communityStates`, `getCommunityState()`, `ensureAddress()`) + persistence orchestration (`initPromise`, `schedulePersist()`) | +| `mock/session.ts` | SIWE endpoints, nonce handling, and the `cookie`-auth-mode session-cookie simulation | +| `mock/core.ts` | Meta/community/resource/policy reads and wallet verification | +| `mock/members.ts` | Member reads and self-service profile mutation | +| `mock/analytics.ts` | Admin analytics summary + the `AnalyticsDataSource` surface | +| `mock/webhooks.ts` | Webhook feed, event replay, admin event log | +| `mock/approvals.ts` | Role/policy mutations and the multi-approval pending-action flow | +| `mock/social.ts` | Connections, privacy settings, blocking | +| `mock/moderation.ts` | Moderation report queue | +| `mock/governance.ts` | Proposals and weighted voting | +| `mock/controls.ts` | Fault-injection knobs (`setMockRoleMutationFailure()`, `setMockResourceFetchFailure/Delay()`) and the API-version override | +| `mock/scenarios.ts` | Scenario presets and `resetMockData()` | + +**Behavior (Simulation Logic):** +- **SIWE simulation:** `getNonce()`, `siweVerify()`, `siweLogout()` (no real cryptography) β€” `mock/session.ts` +- **Session state:** Mock cookie reading/writing for `cookie` auth mode β€” `mock/session.ts` +- **Scenario presets:** Scripted test scenarios (active member, expired member, denied access, etc.) β€” `mock/scenarios.ts` +- **Event replay:** Replay webhook events (e.g., for testing event-driven UI updates) β€” `mock/webhooks.ts` +- **Failure injection:** `setMockRoleMutationFailure()`, `setMockResourceFetchFailure()`, `setMockResourceFetchDelay()` β€” `mock/controls.ts` +- **Version override:** `setMockMetaVersion()` β€” `mock/controls.ts` + +**MockAccessApi class:** Implements the `AccessApi` interface by forwarding each method to the matching domain module: +- Read methods: `getSession()`, `getCommunity()`, `listMembers()`, `getMembership()`, etc. - Write methods: `assignRole()`, `updatePolicy()`, `updateProfile()`, etc. - SIWE methods: `getNonce()`, `siweVerify()`, `siweLogout()` - -**Internal helpers:** -- `ensureAddress()` β€” Ensures a member record exists -- `randomHex()` β€” Generates mock nonces -- `throwMockUnauthorized()` β€” Simulates auth failures -- Community state management via `getCommunityState()`, `communityStates` +- Analytics surface: `analytics` property built by `mock/analytics.ts` **Storage:** Delegates to `mock-storage.ts` for persistence via IndexedDB (with localStorage fallback). @@ -194,13 +196,14 @@ Override the mock API's reported version for compatibility testing. ## Fixture Maintenance Rules -When modifying fixtures in `mock.ts`: +When modifying fixtures in `mock/fixtures.ts`: -1. **Changes are internal only** β€” Never export fixture constants. They are consumed by `MockAccessApi` methods only. +1. **Changes are internal only** β€” Fixture constants are consumed by the mock domain modules only, never application code. 2. **Preserve defaults** β€” Default fixtures represent the "reset" state. `resetMockData()` restores them; any changes should be intentional. 3. **Seed stability** β€” The 50,000 synthetic members are generated deterministically; changing the seed names or generation logic affects all downstream member lookups and pagination tests. 4. **Scenario implications** β€” Fixture additions affect all scenarios. If adding a new resource, consider whether scenarios should reference it. 5. **Type alignment** β€” Ensure fixture data conforms to types in `types.ts`. Zod schemas help catch drift during testing. +6. **Mutable stores** β€” `mockConnections`, `mockPrivacySettings`, and `mockReports` are top-level `let` bindings. Reassign them via the exported setters in `fixtures.ts` (reassigning an imported binding is forbidden by ESM module semantics); content/property mutations are fine from anywhere. --- @@ -208,7 +211,7 @@ When modifying fixtures in `mock.ts`: When adding new behavior to the mock (e.g., new test failure mode): -1. **Implement in `MockAccessApi` methods** β€” Behavior lives where it's called, not in separate helper files. +1. **Implement in the matching domain module** β€” Add the logic to the `lib/api/mock/` module for that domain (e.g., governance β†’ `mock/governance.ts`), and forward to it from the `MockAccessApi` method in `mock.ts`. If a new domain appears, give it its own focused module under `lib/api/mock/`. 2. **Expose toggles via `mock-boundary.ts`** β€” If developers need to enable/disable the behavior, export a setter (e.g., `setMockXyzFail()`). 3. **Document in this file** β€” Add the new control to the "Developer Controls" section. 4. **Test both modes** β€” E2E tests should verify the behavior works in both mock and live modes (or skip live if the failure cannot be easily reproduced). @@ -237,7 +240,7 @@ The entire flow is transparent to the component; it only sees the `AccessApi` in ### Safe Changes These changes can happen without breaking consumers: -- Reorganizing fixture data within `mock.ts` (e.g., moving defaults into a separate file) +- Reorganizing fixture data or moving a mock domain into its own module under `lib/api/mock/` (e.g., adding a dedicated module for a new API domain) - Adding new developer controls to `mock-boundary.ts` and re-exporting them from `index.ts` - Changing mock storage backend (e.g., from IndexedDB to SQLite) as long as `persistState()`/`loadPersistedState()` signature stays the same - Optimizing fixture generation (e.g., lazy-loading the 50k members) diff --git a/lib/api/errors.ts b/lib/api/errors.ts index 15cebf8..deac412 100644 --- a/lib/api/errors.ts +++ b/lib/api/errors.ts @@ -10,6 +10,7 @@ export type ApiErrorCode = | 'bad_request' | 'unknown_error' | 'conflict' + | 'invalid_state' | 'aborted'; export interface ApiErrorOptions { diff --git a/lib/api/mock.ts b/lib/api/mock.ts index 8d1f552..42c4848 100644 --- a/lib/api/mock.ts +++ b/lib/api/mock.ts @@ -2,685 +2,162 @@ * lib/api/mock.ts * * In-memory mock API for local development and testing. - * All existing member/resource/policy data and mutation logic is preserved. * - * SIWE additions: - * - getNonce() β€” returns a random hex string (no real cryptography needed) - * - siweVerify() β€” immediately returns a mock SiweAuthSession with a 1-hour - * expiry WITHOUT verifying the signature. This lets developers - * work in mock mode without MetaMask. - * - siweLogout() β€” no-op that resolves immediately. + * The implementation is organised into focused modules under `lib/api/mock/` + * so a structural mistake in one domain cannot break the whole API layer: * - * Session simulation: - * Set NEXT_PUBLIC_MOCK_SESSION_STATE to control the simulated auth boundary: - * "expired" β€” siweVerify returns an already-expired access token - * with a valid refresh token so renewal can be tested - * "unauthenticated" β€” siweVerify always throws, simulating a backend rejection - * (default) β€” normal mock behaviour (instant auth, 1-hour token) + * - fixtures.ts β€” fixture/seeded data (communities, members, events…) + * - state.ts β€” the in-memory per-community store + persistence + * - session.ts β€” SIWE endpoints + cookie-session simulation + * - core.ts β€” meta/community/resource/policy reads, wallet verification + * - members.ts β€” member reads + self-service profile mutation + * - analytics.ts β€” admin analytics summary + AnalyticsDataSource + * - webhooks.ts β€” webhook feed, replay, admin event log + * - approvals.ts β€” role/policy mutations + multi-approval pending actions + * - social.ts β€” connections, privacy settings, blocks + * - moderation.ts β€” moderation report queue + * - governance.ts β€” proposals and weighted voting + * - controls.ts β€” fault-injection knobs + API-version override + * - scenarios.ts β€” developer scenario presets + mock reset * - * The mock MOCK_ADMIN_ADDRESS constant seeds a pre-authenticated admin for - * convenience so you can simulate both unauthenticated and admin states: - * NEXT_PUBLIC_MOCK_ADMIN_ADDRESS=0xYourAddress + * This module is the stable aggregation point: it composes `MockAccessApi` + * from the domain modules and re-exports every symbol that consumers of + * `lib/api/mock` (and `lib/api/index.ts`) relied on historically. * - * Scenario presets and reset functionality for developer testing are also included. + * All existing member/resource/policy data and mutation logic is preserved. */ -import { PolicyValidationError, validatePolicy } from '../validation/policy' -import { ProfileValidationError, validateProfile } from '../validation/profile' +import { config } from '../config' +import { buildAnalyticsDataSource, mockGetAnalyticsSummary } from './mock/analytics' +import { + mockApproveAction, + mockAssignRole, + mockGetPendingActions, + mockRejectAction, + mockRemoveRole, + mockUpdateApprovalConfig, + mockUpdatePolicy, +} from './mock/approvals' +import { + mockGetCommunity, + mockGetMeta, + mockGetPolicy, + mockGetResource, + mockListPolicies, + mockListResources, + mockVerifyWallet, +} from './mock/core' +import { + MOCK_META_VERSION_OVERRIDE, + setMockMetaVersion, + setMockResourceFetchDelay, + setMockResourceFetchFailure, + setMockRoleMutationFailure, +} from './mock/controls' +import { mockConnections, mockPrivacySettings, mockReports } from './mock/fixtures' +import { + mockCastVote, + mockCloseProposalVoting, + mockCreateProposal, + mockDeleteProposal, + mockGetMemberVote, + mockGetProposal, + mockListProposalVotes, + mockListProposals, + mockPublishProposal, + mockResolveProposal, + mockUpdateProposal, +} from './mock/governance' +import { + mockGetMembership, + mockGetProfile, + mockListMembers, + mockUpdateProfile, +} from './mock/members' +import { mockGetReport, mockListReports, mockUpdateReportState } from './mock/moderation' +import { applyMockScenario, resetMockData } from './mock/scenarios' +import { + mockGetNonce, + mockGetSession, + mockGetSessionStatus, + mockSiweLogout, + mockSiweRefresh, + mockSiweVerify, +} from './mock/session' +import { + mockAcceptConnectionRequest, + mockBlockMember, + mockCreateConnectionRequest, + mockGetConnections, + mockGetPrivacySettings, + mockRejectConnectionRequest, + mockUnblockMember, + mockUpdatePrivacySettings, +} from './mock/social' +import { + communityStates, + getCommunityState, + type CommunityState, + type MockApiContext, +} from './mock/state' import { + mockListAdminEvents, + mockListWebhookEvents, + mockReplayEvent, + mockSubscribeWebhookEvents, + replayMockEvent, +} from './mock/webhooks' +import type { AccessApi, AccessPolicy, + AdminEventFilterParams, + AnalyticsDataSource, AnalyticsSummary, + ApprovalConfig, Community, + Connection, + MemberPrivacySettings, MemberProfile, MemberRow, Membership, - MembershipTier, MetaResponse, + ModerationReport, + ModerationState, + Paginated, PaginatedMembers, + PendingAction, + Proposal, + ProposalStatus, + ProposalType, Resource, ResourceLookupResult, Role, Session, SessionStatus, SiweAuthSession, + Vote, + VoteChoice, WalletVerification, + WebhookEvent, WebhookEventLog, WebhookEventUnsubscribe, - Connection, - MemberPrivacySettings, - ModerationReport, - ModerationState, - AdminEventFilterParams, - Paginated, - WebhookEvent, - EXPECTED_API_VERSION, - PendingAction, - ApprovalConfig, - PendingActionType, - PendingActionPayload, - Proposal, - Vote, - VoteChoice, - VotesSummary, - ProposalStatus, - ProposalType, } from './types' -import { ApiError } from './errors' -import { - loadPersistedState, - persistState, - clearPersistedState, - LS_KEY, -} from './mock-storage' -import { config } from '../config' -import { - MOCK_ANALYTICS_SUMMARY, - getResourceAccess, - getMemberGrowth, - getMockAnalyticsSummary, -} from './analytics/mock' -/** Read once at module load so it is stable across renders. */ -const MOCK_SESSION_STATE = - (typeof process !== 'undefined' && - process.env.NEXT_PUBLIC_MOCK_SESSION_STATE) || - '' - -// ── Mock cookie-session simulation (cookie auth mode) ─────────────────────── -// -// There is no real backend in mock mode, so a real httpOnly cookie can't be -// set. This uses a plain, non-httpOnly document.cookie entry to simulate -// "the browser is holding a session cookie" β€” an honest simulation boundary -// (mock JS genuinely cannot set an httpOnly cookie either). It intentionally -// never touches sessionStorage, so cookie-mode session state is provably -// independent of the bearer-token sessionStorage path in lib/session.ts. -// Only ever written/read when config.authMode === 'cookie', so bearer-mode -// mock runs get zero new side effects. - -const MOCK_SESSION_COOKIE = 'gp_mock_session' - -function setMockSessionCookie(address: string, expiresAt: string): void { - if (typeof document === 'undefined') return - const value = encodeURIComponent(`${address}|${expiresAt}`) - document.cookie = `${MOCK_SESSION_COOKIE}=${value}; path=/; SameSite=Lax` -} - -function clearMockSessionCookie(): void { - if (typeof document === 'undefined') return - document.cookie = `${MOCK_SESSION_COOKIE}=; path=/; Max-Age=0; SameSite=Lax` -} - -function readMockSessionCookie(): { address: string; expiresAt: string } | null { - if (typeof document === 'undefined') return null - const row = document.cookie - .split('; ') - .find((entry) => entry.startsWith(`${MOCK_SESSION_COOKIE}=`)) - if (!row) return null - const raw = decodeURIComponent(row.slice(MOCK_SESSION_COOKIE.length + 1)) - const [address, expiresAt] = raw.split('|') - return address && expiresAt ? { address, expiresAt } : null -} - -import { - DEFAULT_COMMUNITY, - DEFAULT_RESOURCES, - DEFAULT_POLICIES, - DEFAULT_WEBHOOK_EVENTS, - DEFAULT_MEMBER_STORE, - MOCK_COMMUNITIES, - MOCK_RESOURCES, - MOCK_POLICIES, - MOCK_MEMBER_STORES, +export { + applyMockScenario, + communityStates, + getCommunityState, + MOCK_META_VERSION_OVERRIDE, mockConnections, mockPrivacySettings, mockReports, -} from './mock/fixtures'; - -export { mockConnections, mockPrivacySettings, mockReports }; - -export interface CommunityState { - community: Community - resources: Resource[] - policies: AccessPolicy[] - webhookEvents: WebhookEventLog[] - memberStore: Record - pendingActions: PendingAction[] - proposals: Record - votes: Record // Maps vote ID to Vote -} - -export let communityStates: Record = {} - -export function getCommunityState(communityId: string = 'guildpass-demo'): CommunityState { - const normalizedId = MOCK_COMMUNITIES[communityId] ? communityId : 'guildpass-demo' - if (!communityStates[normalizedId]) { - communityStates[normalizedId] = { - community: { ...MOCK_COMMUNITIES[normalizedId] }, - resources: [...(MOCK_RESOURCES[normalizedId] ?? [])], - policies: [...(MOCK_POLICIES[normalizedId] ?? [])], - webhookEvents: [...DEFAULT_WEBHOOK_EVENTS], - memberStore: Object.fromEntries( - Object.entries(MOCK_MEMBER_STORES[normalizedId] ?? {}).map(([k, v]) => [ - k, - { ...v, roles: [...v.roles], membership: { ...v.membership }, profile: { ...v.profile } } - ]) - ), - pendingActions: [], - proposals: {}, - votes: {}, - } - } - return communityStates[normalizedId] -} - -function createMockStreamEvent(communityId: string = 'guildpass-demo'): WebhookEventLog { - const state = getCommunityState(communityId) - const base = DEFAULT_WEBHOOK_EVENTS[Math.floor(Math.random() * DEFAULT_WEBHOOK_EVENTS.length)] - const statuses: WebhookEventLog['status'][] = ['success', 'pending', 'failed'] - const event: WebhookEventLog = { - ...base, - id: `stream_${Date.now()}_${Math.random().toString(16).slice(2)}`, - timestamp: new Date().toISOString(), - status: statuses[Math.floor(Math.random() * statuses.length)], - isReplay: false, - fullPayload: { - ...(base.fullPayload ?? base.payloadSummary), - source: 'mock-sse-stream', - }, - } - state.webhookEvents.unshift(event) - return event -} - -let saveTimeout: ReturnType | null = null - -async function saveState() { - if (saveTimeout) clearTimeout(saveTimeout) - saveTimeout = setTimeout(async () => { - await persistState({ communityStates } as any) - }, 100) -} - -function schedulePersist(): void { - saveState().catch(() => {}) -} - -const initPromise = loadPersistedState().then((persisted) => { - if (!persisted) { - for (const cid of Object.keys(MOCK_COMMUNITIES)) { - getCommunityState(cid) - } - return - } - if ((persisted as any).communityStates) { - communityStates = (persisted as any).communityStates - } else { - // Backward compatibility: load legacy state into guildpass-demo - communityStates['guildpass-demo'] = { - community: (persisted as any).community || { ...DEFAULT_COMMUNITY }, - resources: (persisted as any).resources || [...DEFAULT_RESOURCES], - policies: (persisted as any).policies || [...DEFAULT_POLICIES], - webhookEvents: (persisted as any).webhookEvents || [...DEFAULT_WEBHOOK_EVENTS], - memberStore: (persisted as any).memberStore || { ...DEFAULT_MEMBER_STORE }, - pendingActions: (persisted as any).pendingActions || [], - } - } - for (const cid of Object.keys(MOCK_COMMUNITIES)) { - getCommunityState(cid) - } -}) - -if (typeof window !== 'undefined') { - window.addEventListener('beforeunload', () => { - if (saveTimeout) clearTimeout(saveTimeout) - try { - localStorage.setItem(LS_KEY, JSON.stringify({ communityStates })) - } catch { /* ignore */ } - }) -} - -function ensureAddress(addr?: string, communityId: string = 'guildpass-demo') { - if (!addr) return null - const state = getCommunityState(communityId) - if (!state.memberStore[addr]) { - state.memberStore[addr] = { - membership: { - address: addr, - tier: 'free', - active: true, - }, - roles: ['member'], - profile: { - address: addr, - displayName: `User ${addr.slice(0, 6)}`, - badges: ['Early Member', 'Beta Tester'], - }, - } - } - return state.memberStore[addr] -} - -type MockScenario = - | 'active-member' - | 'expired-member' - | 'denied-resource' - | 'admin-session-expired' - | 'no-roles' - | 'multiple-roles' - | 'multiple-communities' - | 'concurrent-policy-edit' - | 'customized-profile' - -/** - * Replay a webhook event by cloning it into the mock event store. - * The clone is marked with `isReplay: true` and inserted at the top - * of the feed with a `pending` status so it is visually distinct. - * - * This function operates directly on the module-level mock store and - * is intended for use by the admin event replay tool. It must only be - * called when `config.apiMode === 'mock'`. - */ -export async function replayMockEvent(eventId: string, communityId: string = 'guildpass-demo'): Promise { - await initPromise - const state = getCommunityState(communityId) - const original = state.webhookEvents.find((e) => e.id === eventId) - if (!original) { - throw new ApiError({ - status: 404, - code: 'not_found', - safeMessage: `Event "${eventId}" not found in mock store.`, - }) - } - - const replay: WebhookEventLog = { - ...original, - id: `replay_${eventId}_${Date.now()}`, - timestamp: new Date().toISOString(), - isReplay: true, - status: 'pending', - fullPayload: original.fullPayload ?? { ...original.payloadSummary }, - } - - state.webhookEvents.unshift(replay) - schedulePersist() - - // Apply side effects to the member store for recognised event types. - const addr = original.affectedIdentifier - if (addr && addr.startsWith('0x')) { - const existing = state.memberStore[addr] - switch (original.eventType) { - case 'membership.created': - case 'membership.renewed': { - const tier = (original.payloadSummary.tier as MembershipTier) ?? 'free' - state.memberStore[addr] = { - membership: { address: addr, tier, active: true }, - roles: existing?.roles ?? ['member'], - profile: existing?.profile ?? { address: addr, displayName: `Replayed ${addr.slice(0, 6)}`, badges: [] }, - } - break - } - case 'membership.expired': - if (existing) { - state.memberStore[addr] = { - ...existing, - membership: { ...existing.membership, active: false }, - } - } - break - case 'tier.upgraded': { - const newTier = (original.payloadSummary.tier as MembershipTier) ?? 'standard' - if (existing) { - state.memberStore[addr] = { - ...existing, - membership: { ...existing.membership, tier: newTier }, - } - } - break - } - // policy.updated β€” no member-store side effect - } - } - - return replay -} - -/** - * Reset all mock data to its initial state. - */ -export async function resetMockData() { - await initPromise - communityStates = {} - for (const cid of Object.keys(MOCK_COMMUNITIES)) { - getCommunityState(cid) - } - mockRoleMutationShouldFail = false - mockResourceFetchFailure = false - mockResourceFetchDelayMs = 0 - await clearPersistedState() -} - -/** - * Apply a predefined scenario preset for testing. - */ -export async function applyMockScenario(scenario: MockScenario, address: string = '0x1234567890123456789012345678901234567890') { - await resetMockData() - - const demoState = getCommunityState('guildpass-demo') - - switch (scenario) { - case 'active-member': - demoState.memberStore[address] = { - membership: { - address, - tier: 'standard', - active: true, - }, - roles: ['member'], - profile: { - address, - displayName: 'Active Standard User', - badges: ['Early Member', 'Standard Tier'], - }, - } - break - - case 'expired-member': - demoState.memberStore[address] = { - membership: { - address, - tier: 'standard', - active: false, - expiresAt: new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(), - }, - roles: ['member'], - profile: { - address, - displayName: 'Expired User', - badges: ['Former Member'], - }, - } - break - - case 'denied-resource': - demoState.memberStore[address] = { - membership: { - address, - tier: 'free', - active: true, - }, - roles: ['member'], - profile: { - address, - displayName: 'Free Tier User', - badges: ['Free Tier'], - }, - } - // Ensure Alpha Docs require standard tier - demoState.policies = demoState.policies.map(p => - p.resourceId === 'alpha' - ? { ...p, minTier: 'standard' } - : p - ) - break - - case 'admin-session-expired': - demoState.memberStore[address] = { - membership: { - address, - tier: 'pro', - active: true, - }, - roles: ['admin', 'member'], - profile: { - address, - displayName: 'Expired Admin', - badges: ['Admin', 'Pro Tier'], - }, - } - break - - case 'no-roles': - demoState.memberStore[address] = { - membership: { - address, - tier: 'free', - active: true, - }, - roles: [], - profile: { - address, - displayName: 'No Roles User', - badges: ['New User'], - }, - } - break - - case 'multiple-roles': - // 'admin' is included deliberately: it's the only role that changes - // nav/admin-console visibility in this codebase (every first-party - // module in lib/admin-modules/modules/*.ts requires it), so leaving it - // out would make role-aware nav unverifiable. It does not bypass - // tier-gated resource access (lib/api/access-decision.ts evaluates - // tier independently of role), so alpha/pro-reports stay genuinely - // tier-gated for this member. - demoState.memberStore[address] = { - membership: { - address, - tier: 'pro', - active: true, - }, - roles: ['admin', 'moderator', 'member'], - profile: { - address, - displayName: 'Multi-Role Member', - badges: ['Admin', 'Moderator'], - }, - } - break - - case 'multiple-communities': - // Seed a member whose data reflects participation in more than one - // community. The mock session model exposes a single active community, - // so this preset points the active community at a multi-community hub - // and marks the member's badges to reflect their other memberships. - // Existing single-community presets are unaffected. - const hubState = getCommunityState('guildpass-hub') - hubState.community = { - id: 'guildpass-hub', - name: 'GuildPass Hub (Multi-Community)', - description: - 'Shared hub for a member active across several communities', - tiers: ['free', 'standard', 'pro'], - } - hubState.memberStore[address] = { - membership: { - address, - tier: 'standard', - active: true, - }, - roles: ['member'], - profile: { - address, - displayName: 'Multi-Community Member', - badges: [ - 'GuildPass Demo Community', - 'Builders Collective', - 'Design Guild', - ], - }, - } - break - - case 'concurrent-policy-edit': - // Set up a scenario to test concurrent policy editing - demoState.memberStore[address] = { - membership: { - address, - tier: 'pro', - active: true, - }, - roles: ['admin', 'member'], - profile: { - address, - displayName: 'Admin Testing Concurrency', - badges: ['Admin', 'Pro Tier'], - }, - } - // Update the 'alpha' policy with a very recent timestamp to simulate - // another admin just having edited it - const alphaIdx = demoState.policies.findIndex((p) => p.resourceId === 'alpha') - if (alphaIdx >= 0) { - demoState.policies[alphaIdx] = { - ...demoState.policies[alphaIdx], - updatedAt: new Date(Date.now() - 1000 * 5).toISOString(), // 5 seconds ago - minTier: 'pro', // Changed from 'standard' - } - } - break - - case 'customized-profile': - // A member who has filled out every rich-profile field (#254), to - // exercise the public profile view and editor pre-fill against a - // fully-populated record rather than only the sparse defaults. - demoState.memberStore[address] = { - membership: { - address, - tier: 'standard', - active: true, - }, - roles: ['member'], - profile: { - address, - displayName: 'Ada Lovelace', - bio: 'Builder and early GuildPass member, exploring what token-gated communities can look like.', - avatar: 'https://example.com/avatars/ada-lovelace.png', - socialLinks: [ - { platform: 'twitter', url: 'https://example.com/twitter/ada' }, - { platform: 'github', url: 'https://example.com/github/ada' }, - { platform: 'website', url: 'https://example.com/ada' }, - ], - badges: ['Early Member', 'Standard Tier'], - }, - } - break - } - schedulePersist() -} - -/** Nonce TTL in milliseconds (5 minutes β€” mirrors siwe-go default). */ -const NONCE_TTL_MS = 5 * 60 * 1000 - -/** Extract the nonce value from an EIP-4361 message string. */ -function extractNonceFromMessage(message: string): string | null { - const match = message.match(/Nonce:\s*(\S+)/) - return match ? match[1] : null -} - -/** Generate a short random hex nonce (16 bytes). */ -function randomHex(): string { - return Array.from({ length: 16 }, () => - Math.floor(Math.random() * 256) - .toString(16) - .padStart(2, '0'), - ).join('') -} - -/** Throw a mock 401 ApiError β€” mirrors what the live API throws on expired tokens. */ -function throwMockUnauthorized(): never { - throw new ApiError({ - status: 401, - code: 'unauthorized', - safeMessage: 'Session expired. Please sign in again.', - }) -} - -/** - * When true, the next assignRole()/removeRole() call throws a generic - * (non-auth) failure instead of succeeding β€” issue #243. This exists - * alongside NEXT_PUBLIC_MOCK_SESSION_STATE=expired rather than reusing it: - * that flag is read once at module load and specifically simulates auth/ - * session state, whereas this is a runtime-togglable flag for exercising - * the optimistic-update rollback path for an ordinary server error, from - * either a test or the /developer dev-tools page. Reset by resetMockData(). - */ -let mockRoleMutationShouldFail = false - -/** - * Toggle a simulated non-auth failure for the next assignRole()/ - * removeRole() call(s). Mock-only β€” LiveAccessApi has no equivalent, and - * this must never be called from application code, only from tests or the - * /developer page. - */ -export function setMockRoleMutationFailure(shouldFail: boolean): void { - mockRoleMutationShouldFail = shouldFail -} - -/** - * When set, the next getResource()/getPolicy() call(s) simulate an - * operational failure instead of succeeding β€” used to verify loading and - * error-boundary behaviour in mock mode without a real backend. - * 'network' simulates a transport-level failure (fetch rejection); - * 'server' simulates an HTTP 5xx. Reset by resetMockData(). - */ -let mockResourceFetchFailure: 'network' | 'server' | false = false - -/** Optional artificial delay (ms) applied before getResource()/getPolicy() resolve or fail. */ -let mockResourceFetchDelayMs = 0 - -/** - * Toggle a simulated operational failure for getResource()/getPolicy(). - * Mock-only β€” LiveAccessApi has no equivalent. Intended for tests and the - * /developer page, never application code. - */ -export function setMockResourceFetchFailure(mode: 'network' | 'server' | false): void { - mockResourceFetchFailure = mode -} - -/** Set an artificial delay (ms) before getResource()/getPolicy() settle. Pass 0 to disable. */ -export function setMockResourceFetchDelay(ms: number): void { - mockResourceFetchDelayMs = ms -} - -function mockResourceFetchError(): ApiError { - return mockResourceFetchFailure === 'network' - ? new ApiError({ - code: 'network_error', - safeMessage: 'Unable to connect. Please check your connection and try again.', - retryable: true, - }) - : new ApiError({ - status: 500, - code: 'server_error', - safeMessage: 'The server could not complete the request. Please try again.', - retryable: true, - }) -} - -/** Throw a mock 500 ApiError β€” simulates an ordinary (non-auth) server failure. */ -function throwMockRoleMutationFailure(): never { - throw new ApiError({ - status: 500, - code: 'server_error', - safeMessage: 'Simulated role mutation failure (mock mode).', - retryable: true, - }) -} - -/** - * Override the mock backend's advertised API contract version. - * Set to `null` to restore the default (matches EXPECTED_API_VERSION). - * When set, `getMeta()` returns this version, which can be used to - * simulate an incompatible backend. - */ -export let MOCK_META_VERSION_OVERRIDE: string | null = null - -/** - * Set the mock backend's advertised API contract version. Pass `null` to - * restore the default behaviour (matches the frontend's expected version). - */ -export function setMockMetaVersion(version: string | null): void { - MOCK_META_VERSION_OVERRIDE = version + replayMockEvent, + resetMockData, + setMockMetaVersion, + setMockResourceFetchDelay, + setMockResourceFetchFailure, + setMockRoleMutationFailure, } +export type { CommunityState, MockApiContext } export class MockAccessApi implements AccessApi { /** In-memory nonce store keyed by nonce value β†’ creation timestamp. */ @@ -689,1119 +166,245 @@ export class MockAccessApi implements AccessApi { readonly address?: string readonly communityId: string + /** Analytics surface exposed on the API client (see AdminAccessApi). */ + public analytics: AnalyticsDataSource + constructor( address?: string, communityId?: string, ) { this.address = address this.communityId = communityId ?? 'guildpass-demo' + this.analytics = buildAnalyticsDataSource({ + address: this.address, + communityId: this.communityId, + authMode: config.authMode, + }) } - async getMeta(_signal?: AbortSignal): Promise { - await initPromise + /** Fresh per-call context so config-derived values are never stale. */ + #ctx(): MockApiContext { return { - version: MOCK_META_VERSION_OVERRIDE ?? EXPECTED_API_VERSION, - commit: 'mock-commit-sha', - uptime: (typeof process !== 'undefined' && typeof process.uptime === 'function') ? Math.floor(process.uptime()) : 0, + address: this.address, + communityId: this.communityId, + authMode: config.authMode, } } // ── Read-only ────────────────────────────────────────────────────────────── - async getSession(_signal?: AbortSignal): Promise { - await initPromise - const MOCK_SESSION_STATE = process.env.NEXT_PUBLIC_MOCK_SESSION_STATE || 'valid' - const state = getCommunityState(this.communityId) - if (MOCK_SESSION_STATE === 'cleared') { - return { - // No authenticated session - roles: [], - community: state.community, - } - } + async getMeta(_signal?: AbortSignal): Promise { + return mockGetMeta(_signal) + } - const data = ensureAddress(this.address, this.communityId) - return { - address: this.address, - roles: data ? data.roles : [], - membership: data ? data.membership : undefined, - community: state.community, - ...(data ? { badges: data.profile.badges } : {}), - } + async getSession(_signal?: AbortSignal): Promise { + return mockGetSession(this.#ctx(), _signal) } async getCommunity(_signal?: AbortSignal): Promise { - await initPromise - return getCommunityState(this.communityId).community + return mockGetCommunity(this.#ctx(), _signal) } async getMembership(address: string, _signal?: AbortSignal): Promise { - await initPromise - const data = ensureAddress(address, this.communityId) - return data?.membership ?? null + return mockGetMembership(this.#ctx(), address, _signal) } async getProfile(address: string, _signal?: AbortSignal): Promise { - await initPromise - const data = ensureAddress(address, this.communityId) - return data?.profile ?? null + return mockGetProfile(this.#ctx(), address, _signal) } /** - * Updates the caller's own profile. Mirrors the live client's self-service - * ownership check (`this.address` must match `profile.address`) even - * though mock mode has no real signature to verify, so the two clients - * behave the same way from a caller's perspective. `badges` is - * system-assigned and is always preserved from the existing record, - * regardless of what the caller passes. + * Updates the caller's own profile. See lib/api/mock/members.ts for the + * self-service ownership check semantics. */ async updateProfile(profile: MemberProfile): Promise { - await initPromise - if (MOCK_SESSION_STATE === 'expired') throwMockUnauthorized() - - if (!this.address || this.address.toLowerCase() !== profile.address?.toLowerCase()) { - throw new ApiError({ - status: 403, - code: 'forbidden', - safeMessage: 'You can only edit your own profile.', - }) - } - - const result = validateProfile(profile) - if (!result.valid) { - throw new ProfileValidationError(result.errors) - } - - const data = ensureAddress(result.value.address) - if (!data) { - throw new ApiError({ - status: 404, - code: 'not_found', - safeMessage: `Member "${result.value.address}" not found.`, - }) - } - - data.profile = { - ...data.profile, - displayName: result.value.displayName, - bio: result.value.bio, - avatar: result.value.avatar, - socialLinks: result.value.socialLinks, - } - schedulePersist() + return mockUpdateProfile(this.#ctx(), profile) } async listMembers(params?: { cursor?: string; limit?: number; filter?: string }, _signal?: AbortSignal): Promise { - await initPromise - const state = getCommunityState(this.communityId) - let list = Object.values(state.memberStore).map((m) => ({ - address: m.membership.address, - roles: m.roles, - tier: m.membership.tier, - active: m.membership.active, - ...(m.profile.displayName ? { displayName: m.profile.displayName } : {}), - })) - - if (!params) { - return list - } - - if (params.filter) { - const f = params.filter.toLowerCase() - list = list.filter((m) => m.address.toLowerCase().includes(f)) - } - - const limit = params.limit ?? 100 - const cursor = params.cursor ? parseInt(params.cursor, 10) : 0 - - const paginated = list.slice(cursor, cursor + limit) - const nextCursor = cursor + limit < list.length ? String(cursor + limit) : undefined - - return { - members: paginated, - nextCursor, - } + return mockListMembers(this.#ctx(), params, _signal) } async listResources(_signal?: AbortSignal): Promise { - await initPromise - const state = getCommunityState(this.communityId) - return state.resources.map((r) => ({ ...r, roles: r.roles ?? [] })) + return mockListResources(this.#ctx(), _signal) } async listPolicies(_signal?: AbortSignal): Promise { - await initPromise - const state = getCommunityState(this.communityId) - return state.policies.map((p) => ({ ...p, roles: p.roles ?? [] })) + return mockListPolicies(this.#ctx(), _signal) } async getResource(id: string, _signal?: AbortSignal): Promise { - await initPromise - if (mockResourceFetchDelayMs > 0) await new Promise((r) => setTimeout(r, mockResourceFetchDelayMs)) - if (mockResourceFetchFailure) { - return { status: 'error', error: mockResourceFetchError() } - } - const state = getCommunityState(this.communityId) - const r = state.resources.find((x) => x.id === id) - return r - ? { status: 'found', data: { ...r, roles: r.roles ?? [] }, source: 'direct' } - : { status: 'not_found' } + return mockGetResource(this.#ctx(), id, _signal) } async getPolicy(resourceId: string, _signal?: AbortSignal): Promise { - await initPromise - if (mockResourceFetchDelayMs > 0) await new Promise((r) => setTimeout(r, mockResourceFetchDelayMs)) - if (mockResourceFetchFailure) { - throw mockResourceFetchError() - } - const state = getCommunityState(this.communityId) - const p = state.policies.find((x) => x.resourceId === resourceId) - return p ? { ...p, roles: p.roles ?? [] } : null + return mockGetPolicy(this.#ctx(), resourceId, _signal) } // ── Admin queries & mutations ────────────────────────────────────────────── async listWebhookEvents(_signal?: AbortSignal): Promise { - await initPromise - const state = getCommunityState(this.communityId) - return new Promise((resolve) => setTimeout(() => resolve(state.webhookEvents), 300)) + return mockListWebhookEvents(this.#ctx(), _signal) } subscribeWebhookEvents(onEvent: (event: WebhookEventLog) => void): WebhookEventUnsubscribe { - const cid = this.communityId - const intervalId = globalThis.setInterval(() => { - onEvent(createMockStreamEvent(cid)) - }, 5000) - - globalThis.setTimeout(() => onEvent(createMockStreamEvent(cid)), 1000) - return () => globalThis.clearInterval(intervalId) + return mockSubscribeWebhookEvents(this.communityId, onEvent) } async replayEvent(eventId: string): Promise { - await initPromise - const state = getCommunityState(this.communityId) - const original = state.webhookEvents.find((e) => e.id === eventId) - if (!original) { - throw new ApiError({ - status: 404, - code: 'not_found', - safeMessage: `Event "${eventId}" not found in mock store.`, - }) - } - - const replay: WebhookEventLog = { - ...original, - id: `replay_${eventId}_${Date.now()}`, - timestamp: new Date().toISOString(), - isReplay: true, - status: 'pending', - fullPayload: original.fullPayload ?? { ...original.payloadSummary }, - } - - state.webhookEvents.unshift(replay) - schedulePersist() - return replay + return mockReplayEvent(this.#ctx(), eventId) } async getAnalyticsSummary(_signal?: AbortSignal): Promise { - await initPromise - const state = getCommunityState(this.communityId) - const activeCount = Object.values(state.memberStore).filter(m => m.membership.active).length - const totalCount = Object.values(state.memberStore).length - const resourceAccess = state.resources.map(r => ({ - resourceId: r.id, - resourceTitle: r.title, - accessCount: Math.floor(Math.random() * 100) + 10, - deniedCount: Math.floor(Math.random() * 20), - })) - const summary: AnalyticsSummary = { - totalMembers: totalCount, - activeMembers: activeCount, - memberGrowth: Array.from({ length: 30 }, (_, i) => { - const d = new Date() - d.setDate(d.getDate() - (29 - i)) - return { - date: d.toISOString().split('T')[0], - newMembers: Math.floor(Math.random() * 3), - totalMembers: totalCount - (29 - i) * 2, - } - }), - resourceAccess, - generatedAt: new Date().toISOString(), - } - return new Promise((resolve) => - setTimeout(() => resolve(summary), 300), - ) + return mockGetAnalyticsSummary(this.#ctx(), _signal) } async getPendingActions(): Promise { - await initPromise - return getCommunityState(this.communityId).pendingActions + return mockGetPendingActions(this.#ctx()) } async approveAction(id: string): Promise { - await initPromise - const state = getCommunityState(this.communityId) - const action = state.pendingActions.find(a => a.id === id) - if (!action || action.status !== 'pending') return - - const adminAddr = this.address || '0x0000000000000000000000000000000000000001' - if (!action.currentApprovals.includes(adminAddr)) { - action.currentApprovals.push(adminAddr) - } - - if (action.currentApprovals.length >= action.requiredApprovals) { - if (action.type === 'assignRole') { - const data = ensureAddress(action.payload.address!, this.communityId) - if (data && !data.roles.includes(action.payload.role! as Role)) data.roles.push(action.payload.role! as Role) - } else if (action.type === 'removeRole') { - const data = state.memberStore[action.payload.address!] - if (data) data.roles = data.roles.filter(r => r !== action.payload.role!) - } else if (action.type === 'updatePolicy') { - const result = validatePolicy(action.payload.policy!) - if (result.valid) { - const idx = state.policies.findIndex(p => p.resourceId === result.value.resourceId) - const updatedPolicy = { ...result.value, updatedAt: new Date().toISOString() } - if (idx >= 0) state.policies[idx] = updatedPolicy - else state.policies.push(updatedPolicy) - } - } - action.status = 'executed' - } - schedulePersist() + return mockApproveAction(this.#ctx(), id) } async rejectAction(id: string): Promise { - await initPromise - const state = getCommunityState(this.communityId) - const action = state.pendingActions.find(a => a.id === id) - if (action && action.status === 'pending') { - action.status = 'rejected' - schedulePersist() - } + return mockRejectAction(this.#ctx(), id) } async updateApprovalConfig(config: ApprovalConfig): Promise { - await initPromise - const state = getCommunityState(this.communityId) - ;(state.community as any).approvalConfig = config - schedulePersist() - } - - private _checkApproval(type: PendingActionType, payload: PendingActionPayload): { status: 'executed' | 'pending'; pendingActionId?: string } { - const state = getCommunityState(this.communityId) - const config = (state.community as any).approvalConfig - const required = config ? config[type] || 1 : 1 - - if (required > 1) { - const pendingActionId = `pa_${Date.now()}_${Math.random().toString(36).substr(2, 5)}` - const adminAddr = this.address || '0x0000000000000000000000000000000000000001' - state.pendingActions.push({ - id: pendingActionId, - type, - payload, - proposer: adminAddr, - requiredApprovals: required, - currentApprovals: [adminAddr], - status: 'pending', - createdAt: new Date().toISOString() - }) - schedulePersist() - return { status: 'pending', pendingActionId } - } - return { status: 'executed' } + return mockUpdateApprovalConfig(this.#ctx(), config) } async assignRole(address: string, role: Role): Promise<{ status: 'executed' | 'pending'; pendingActionId?: string }> { - await initPromise - if (MOCK_SESSION_STATE === 'expired') throwMockUnauthorized() - if (mockRoleMutationShouldFail) throwMockRoleMutationFailure() - - const check = this._checkApproval('assignRole', { address, role }) - if (check.status === 'pending') return check - - const data = ensureAddress(address, this.communityId) - if (!data) return { status: 'executed' } - if (!data.roles.includes(role)) data.roles.push(role) - schedulePersist() - return { status: 'executed' } + return mockAssignRole(this.#ctx(), address, role) } async removeRole(address: string, role: Role): Promise<{ status: 'executed' | 'pending'; pendingActionId?: string }> { - await initPromise - if (MOCK_SESSION_STATE === 'expired') throwMockUnauthorized() - if (mockRoleMutationShouldFail) throwMockRoleMutationFailure() - - const check = this._checkApproval('removeRole', { address, role }) - if (check.status === 'pending') return check - - const state = getCommunityState(this.communityId) - const data = state.memberStore[address] - if (!data) return { status: 'executed' } - data.roles = data.roles.filter((r) => r !== role) - schedulePersist() - return { status: 'executed' } + return mockRemoveRole(this.#ctx(), address, role) } async updatePolicy(policy: AccessPolicy): Promise<{ status: 'executed' | 'pending'; pendingActionId?: string }> { - await initPromise - if (MOCK_SESSION_STATE === 'expired') throwMockUnauthorized() - const result = validatePolicy(policy) - - if (!result.valid) { - throw new PolicyValidationError(result.errors) - } - - const state = getCommunityState(this.communityId) - const idx = state.policies.findIndex((p) => p.resourceId === result.value.resourceId) - - // Optimistic concurrency control: check if policy was modified since load - if (idx >= 0 && policy.updatedAt) { - const existingPolicy = state.policies[idx] - if (existingPolicy.updatedAt && existingPolicy.updatedAt !== policy.updatedAt) { - throw new ApiError({ - status: 409, - code: 'conflict', - safeMessage: 'This policy was modified by another user. Please reload and try again.', - details: { - currentUpdatedAt: existingPolicy.updatedAt, - providedUpdatedAt: policy.updatedAt, - }, - }) - } - } - - const check = this._checkApproval('updatePolicy', { policy }) - if (check.status === 'pending') return check - - // Update policy with new timestamp - const updatedPolicy = { - ...result.value, - updatedAt: new Date().toISOString(), - } - - if (idx >= 0) state.policies[idx] = updatedPolicy - else state.policies.push(updatedPolicy) - schedulePersist() - return { status: 'executed' } + return mockUpdatePolicy(this.#ctx(), policy) } async listAdminEvents(params?: AdminEventFilterParams): Promise> { - let events = getCommunityState(this.communityId).webhookEvents as any[] - - if (params?.types && params.types.length > 0) { - events = events.filter((e) => params.types!.includes(e.type)) - } - - if (params?.startDate) { - const start = new Date(params.startDate) - events = events.filter((e) => new Date(e.createdAt) >= start) - } - - if (params?.endDate) { - // Include the end date fully (e.g., up to end of the day) - const end = new Date(params.endDate) - end.setUTCHours(23, 59, 59, 999) - events = events.filter((e) => new Date(e.createdAt) <= end) - } - - const page = params?.page || 1 - const limit = params?.limit || 20 - const startIndex = (page - 1) * limit - - const paginated = events.slice(startIndex, startIndex + limit) - - return { - data: paginated, - total: events.length, - page, - limit - } + return mockListAdminEvents(this.#ctx(), params) } // ── SIWE mock endpoints ──────────────────────────────────────────────────── - async getNonce(_address: string): Promise { - await initPromise - const nonce = randomHex() - this.#nonceStore.set(nonce, Date.now()) - return nonce + async getNonce(address: string): Promise { + return mockGetNonce(this.#ctx(), this.#nonceStore, address) } - async siweVerify(message: string, _signature: string): Promise { - await initPromise - if (MOCK_SESSION_STATE === 'unauthenticated') { - throwMockUnauthorized() - } - - const nonce = extractNonceFromMessage(message) - if (!nonce || !this.#nonceStore.has(nonce)) { - throw new ApiError({ - status: 400, - code: 'bad_request', - safeMessage: 'Nonce not found or already used.', - }) - } - - const createdAt = this.#nonceStore.get(nonce)! - if (Date.now() - createdAt > NONCE_TTL_MS) { - this.#nonceStore.delete(nonce) - throw new ApiError({ - status: 400, - code: 'bad_request', - safeMessage: 'Nonce expired. Please request a new one.', - }) - } - - this.#nonceStore.delete(nonce) - - const expiresAt = - MOCK_SESSION_STATE === 'expired' - ? new Date(Date.now() - 1).toISOString() - : new Date(Date.now() + 60 * 60 * 1000).toISOString() - - const refreshExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString() - const resolvedAddress = this.address ?? '0x0000000000000000000000000000000000000000' - - if (config.authMode === 'cookie') { - setMockSessionCookie(resolvedAddress, expiresAt) - } - - return { - isAuthenticated: true, - token: `mock-jwt-${randomHex()}`, - address: resolvedAddress, - expiresAt, - refreshToken: `mock-refresh-${randomHex()}`, - refreshExpiresAt, - } + async siweVerify(message: string, signature: string): Promise { + return mockSiweVerify(this.#ctx(), this.#nonceStore, message, signature) } async siweRefresh(refreshToken: string): Promise { - await initPromise - // e2e instrumentation only: mock mode makes no real network request for - // siweRefresh, so cross-tab race tests need some observable signal for - // "how many refresh attempts actually happened" per tab. - if (typeof window !== 'undefined') { - (window as any).__mockSiweRefreshCalls__ = - ((window as any).__mockSiweRefreshCalls__ ?? 0) + 1 - } - if (MOCK_SESSION_STATE === 'expired' || MOCK_SESSION_STATE === 'unauthenticated') { - throw new ApiError({ - status: 401, - code: 'unauthorized', - safeMessage: 'Refresh token expired. Please sign in again.', - }) - } - - if (config.authMode === 'cookie') { - // Cookie mode has no refresh-token string for the frontend to hold β€” - // the (mock) session cookie is the only refreshability signal. - if (!readMockSessionCookie()) { - throw new ApiError({ - status: 401, - code: 'unauthorized', - safeMessage: 'Invalid refresh token.', - }) - } - } else if (!refreshToken || !refreshToken.startsWith('mock-refresh-')) { - throw new ApiError({ - status: 401, - code: 'unauthorized', - safeMessage: 'Invalid refresh token.', - }) - } - - const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString() - const refreshExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString() - const resolvedAddress = this.address ?? '0x0000000000000000000000000000000000000000' - - if (config.authMode === 'cookie') { - setMockSessionCookie(resolvedAddress, expiresAt) - } - - return { - isAuthenticated: true, - token: `mock-jwt-${randomHex()}`, - address: resolvedAddress, - expiresAt, - refreshToken: `mock-refresh-${randomHex()}`, - refreshExpiresAt, - } + return mockSiweRefresh(this.#ctx(), refreshToken) } async siweLogout(_token?: string): Promise { - await initPromise - if (config.authMode === 'cookie') { - clearMockSessionCookie() - } + return mockSiweLogout(this.#ctx(), _token) } - /** - * Mock counterpart to LiveAccessApi.getSessionStatus(). Reads only the - * simulated document.cookie session marker set by siweVerify/siweRefresh β€” - * never sessionStorage β€” so cookie-mode session state stays deterministic - * and independent of the bearer-token sessionStorage path. - */ async getSessionStatus(_signal?: AbortSignal): Promise { - await initPromise - if (MOCK_SESSION_STATE === 'unauthenticated') { - return { authenticated: false } - } - const cookie = readMockSessionCookie() - if (!cookie) { - return { authenticated: false } - } - if (MOCK_SESSION_STATE === 'expired' || new Date(cookie.expiresAt).getTime() <= Date.now()) { - return { authenticated: false } - } - return { authenticated: true, address: cookie.address, expiresAt: cookie.expiresAt } + return mockGetSessionStatus(this.#ctx(), _signal) } async verifyWallet(_address: string, _signal?: AbortSignal): Promise { - await initPromise - return { - verified: true, - method: 'mock', - checkedAt: new Date().toISOString(), - } + return mockVerifyWallet(_address, _signal) } // ── 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) - ) + return mockGetConnections(this.#ctx(), address, _signal) } async getPrivacySettings(address: string, _signal?: AbortSignal): Promise { - await initPromise - const addr = address.toLowerCase() - return mockPrivacySettings[addr] || { address, connectionVisibility: 'public' } + return mockGetPrivacySettings(this.#ctx(), address, _signal) } async updatePrivacySettings(address: string, settings: MemberPrivacySettings): Promise { - await initPromise - const addr = address.toLowerCase() - mockPrivacySettings[addr] = settings + return mockUpdatePrivacySettings(this.#ctx(), address, 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() - }) + return mockBlockMember(this.#ctx(), targetAddress) } 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) - ) + return mockUnblockMember(this.#ctx(), targetAddress) } 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() - }) + return mockCreateConnectionRequest(this.#ctx(), targetAddress) } 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() - } + return mockAcceptConnectionRequest(this.#ctx(), targetAddress) } 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) - ) + return mockRejectConnectionRequest(this.#ctx(), targetAddress) } // ── Moderation Queue ── async listReports(_signal?: AbortSignal): Promise { - await initPromise - return mockReports + return mockListReports(_signal) } async getReport(id: string, _signal?: AbortSignal): Promise { - await initPromise - return mockReports.find(r => r.id === id) || null + return mockGetReport(id, _signal) } 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() - } + return mockUpdateReportState(id, state, updates) } // ── Governance ── async listProposals(params?: { filter?: ProposalStatus | ProposalType; limit?: number; cursor?: string }, _signal?: AbortSignal): Promise { - await initPromise - const state = getCommunityState(this.communityId) - let proposals = Object.values(state.proposals) - - if (params?.filter) { - // Filter by status or type - const isStatus = ['draft', 'active', 'closed', 'resolved'].includes(params.filter) - if (isStatus) { - proposals = proposals.filter(p => p.status === params.filter) - } else { - proposals = proposals.filter(p => p.type === params.filter) - } - } - - // Sort by creation date, newest first - proposals.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) - - const limit = params?.limit ?? 20 - const cursor = params?.cursor ? parseInt(params.cursor, 10) : 0 - return proposals.slice(cursor, cursor + limit) + return mockListProposals(this.#ctx(), params, _signal) } async getProposal(id: string, _signal?: AbortSignal): Promise { - await initPromise - const state = getCommunityState(this.communityId) - return state.proposals[id] ?? null + return mockGetProposal(this.#ctx(), id, _signal) } async getMemberVote(proposalId: string, _signal?: AbortSignal): Promise { - await initPromise - if (!this.address) return null - - const state = getCommunityState(this.communityId) - const vote = Object.values(state.votes).find( - v => v.proposalId === proposalId && v.voter.toLowerCase() === this.address!.toLowerCase() - ) - return vote ?? null + return mockGetMemberVote(this.#ctx(), proposalId, _signal) } async listProposalVotes(proposalId: string, params?: { limit?: number; cursor?: string }, _signal?: AbortSignal): Promise { - await initPromise - const state = getCommunityState(this.communityId) - let votes = Object.values(state.votes).filter(v => v.proposalId === proposalId) - - // Sort by vote time, newest first - votes.sort((a, b) => new Date(b.votedAt).getTime() - new Date(a.votedAt).getTime()) - - const limit = params?.limit ?? 20 - const cursor = params?.cursor ? parseInt(params.cursor, 10) : 0 - return votes.slice(cursor, cursor + limit) + return mockListProposalVotes(this.#ctx(), proposalId, params, _signal) } async castVote(proposalId: string, choice: VoteChoice): Promise { - await initPromise - if (MOCK_SESSION_STATE === 'expired') throwMockUnauthorized() - if (!this.address) { - throw new ApiError({ - status: 403, - code: 'forbidden', - safeMessage: 'Must be authenticated to vote.', - }) - } - - const state = getCommunityState(this.communityId) - const proposal = state.proposals[proposalId] - - if (!proposal) { - throw new ApiError({ - status: 404, - code: 'not_found', - safeMessage: 'Proposal not found.', - }) - } - - if (proposal.status !== 'active') { - throw new ApiError({ - status: 400, - code: 'invalid_state', - safeMessage: 'Voting is not currently open for this proposal.', - }) - } - - // Check if already voted - const existingVoteId = Object.entries(state.votes).find( - ([_, v]) => v.proposalId === proposalId && v.voter.toLowerCase() === this.address!.toLowerCase() - )?.[0] - - // Get voter's weight based on tier/role - const memberData = state.memberStore[this.address] - const tier = memberData?.membership.tier ?? 'free' - const role = memberData?.roles[0] ?? 'member' - - // Simple weight: free=1, standard=2, pro=3 (tier) Γ— member=1, moderator=2, admin=3 (role) - const tierWeight: Record = { free: 1, standard: 2, pro: 3 } - const roleMultiplier: Record = { member: 1, moderator: 2, admin: 3 } - const weight = tierWeight[tier] * roleMultiplier[role] - - const vote: Vote = { - id: existingVoteId || `vote_${Date.now()}_${Math.random().toString(16).slice(2)}`, - proposalId, - voter: this.address, - choice, - weight, - voterContext: { tier, role }, - votedAt: new Date().toISOString(), - } - - // Update proposal vote summary - if (existingVoteId) { - const oldVote = state.votes[existingVoteId] - // Remove old vote from summary - proposal.votesSummary.totalVotes-- - proposal.votesSummary.weightsFor -= oldVote.choice === 'for' ? oldVote.weight : 0 - proposal.votesSummary.weightsAgainst -= oldVote.choice === 'against' ? oldVote.weight : 0 - proposal.votesSummary.weightsAbstain -= oldVote.choice === 'abstain' ? oldVote.weight : 0 - } - - // Add new vote to summary - proposal.votesSummary.totalVotes++ - if (choice === 'for') proposal.votesSummary.weightsFor += weight - else if (choice === 'against') proposal.votesSummary.weightsAgainst += weight - else proposal.votesSummary.weightsAbstain += weight - - // Recalculate percentages - if (proposal.totalWeight > 0) { - proposal.votesSummary.percentFor = Math.round((proposal.votesSummary.weightsFor / proposal.totalWeight) * 100) - proposal.votesSummary.percentAgainst = Math.round((proposal.votesSummary.weightsAgainst / proposal.totalWeight) * 100) - } - - state.votes[vote.id] = vote - schedulePersist() - - return vote + return mockCastVote(this.#ctx(), proposalId, choice) } async createProposal(proposal: Omit): Promise { - await initPromise - if (MOCK_SESSION_STATE === 'expired') throwMockUnauthorized() - - // Check if user is admin - if (!this.address || !getCommunityState(this.communityId).memberStore[this.address]?.roles.includes('admin')) { - throw new ApiError({ - status: 403, - code: 'forbidden', - safeMessage: 'Only admins can create proposals.', - }) - } - - const state = getCommunityState(this.communityId) - - // Calculate total weight (sum of all member weights) - let totalWeight = 0 - Object.values(state.memberStore).forEach(member => { - const tierWeight: Record = { free: 1, standard: 2, pro: 3 } - const roleMultiplier: Record = { member: 1, moderator: 2, admin: 3 } - const weight = tierWeight[member.membership.tier] * roleMultiplier[member.roles[0] ?? 'member'] - totalWeight += weight - }) - - const newProposal: Proposal = { - id: `prop_${Date.now()}_${Math.random().toString(16).slice(2)}`, - communityId: this.communityId, - ...proposal, - status: 'draft', - createdAt: new Date().toISOString(), - votesSummary: { - totalVotes: 0, - weightsFor: 0, - weightsAgainst: 0, - weightsAbstain: 0, - }, - totalWeight, - } - - state.proposals[newProposal.id] = newProposal - schedulePersist() - - return newProposal + return mockCreateProposal(this.#ctx(), proposal) } async updateProposal(id: string, updates: Partial>): Promise { - await initPromise - if (MOCK_SESSION_STATE === 'expired') throwMockUnauthorized() - - if (!this.address || !getCommunityState(this.communityId).memberStore[this.address]?.roles.includes('admin')) { - throw new ApiError({ - status: 403, - code: 'forbidden', - safeMessage: 'Only admins can update proposals.', - }) - } - - const state = getCommunityState(this.communityId) - const proposal = state.proposals[id] - - if (!proposal) { - throw new ApiError({ - status: 404, - code: 'not_found', - safeMessage: 'Proposal not found.', - }) - } - - if (proposal.status === 'active' || proposal.status === 'resolved') { - throw new ApiError({ - status: 400, - code: 'invalid_state', - safeMessage: 'Cannot update an active or resolved proposal.', - }) - } - - Object.assign(proposal, updates) - schedulePersist() - - return proposal + return mockUpdateProposal(this.#ctx(), id, updates) } async publishProposal(id: string): Promise { - await initPromise - if (MOCK_SESSION_STATE === 'expired') throwMockUnauthorized() - - if (!this.address || !getCommunityState(this.communityId).memberStore[this.address]?.roles.includes('admin')) { - throw new ApiError({ - status: 403, - code: 'forbidden', - safeMessage: 'Only admins can publish proposals.', - }) - } - - const state = getCommunityState(this.communityId) - const proposal = state.proposals[id] - - if (!proposal) { - throw new ApiError({ - status: 404, - code: 'not_found', - safeMessage: 'Proposal not found.', - }) - } - - if (proposal.status !== 'draft') { - throw new ApiError({ - status: 400, - code: 'invalid_state', - safeMessage: 'Only draft proposals can be published.', - }) - } - - proposal.status = 'active' - schedulePersist() - - return proposal + return mockPublishProposal(this.#ctx(), id) } async closeProposalVoting(id: string): Promise { - await initPromise - if (MOCK_SESSION_STATE === 'expired') throwMockUnauthorized() - - if (!this.address || !getCommunityState(this.communityId).memberStore[this.address]?.roles.includes('admin')) { - throw new ApiError({ - status: 403, - code: 'forbidden', - safeMessage: 'Only admins can close voting.', - }) - } - - const state = getCommunityState(this.communityId) - const proposal = state.proposals[id] - - if (!proposal) { - throw new ApiError({ - status: 404, - code: 'not_found', - safeMessage: 'Proposal not found.', - }) - } - - if (proposal.status !== 'active') { - throw new ApiError({ - status: 400, - code: 'invalid_state', - safeMessage: 'Only active proposals can be closed.', - }) - } - - proposal.status = 'closed' - schedulePersist() - - return proposal + return mockCloseProposalVoting(this.#ctx(), id) } async resolveProposal(id: string, outcome: string): Promise { - await initPromise - if (MOCK_SESSION_STATE === 'expired') throwMockUnauthorized() - - if (!this.address || !getCommunityState(this.communityId).memberStore[this.address]?.roles.includes('admin')) { - throw new ApiError({ - status: 403, - code: 'forbidden', - safeMessage: 'Only admins can resolve proposals.', - }) - } - - const state = getCommunityState(this.communityId) - const proposal = state.proposals[id] - - if (!proposal) { - throw new ApiError({ - status: 404, - code: 'not_found', - safeMessage: 'Proposal not found.', - }) - } - - proposal.status = 'resolved' - proposal.payload = { ...proposal.payload, outcome, resolvedAt: new Date().toISOString() } - schedulePersist() - - return proposal + return mockResolveProposal(this.#ctx(), id, outcome) } async deleteProposal(id: string): Promise { - await initPromise - if (MOCK_SESSION_STATE === 'expired') throwMockUnauthorized() - - if (!this.address || !getCommunityState(this.communityId).memberStore[this.address]?.roles.includes('admin')) { - throw new ApiError({ - status: 403, - code: 'forbidden', - safeMessage: 'Only admins can delete proposals.', - }) - } - - const state = getCommunityState(this.communityId) - const proposal = state.proposals[id] - - if (!proposal) { - throw new ApiError({ - status: 404, - code: 'not_found', - safeMessage: 'Proposal not found.', - }) - } - - if (proposal.status !== 'draft') { - throw new ApiError({ - status: 400, - code: 'invalid_state', - safeMessage: 'Only draft proposals can be deleted.', - }) - } - - delete state.proposals[id] - // Also delete any votes on this proposal - Object.keys(state.votes).forEach(voteId => { - if (state.votes[voteId].proposalId === id) { - delete state.votes[voteId] - } - }) - schedulePersist() + return mockDeleteProposal(this.#ctx(), id) } - - public analytics: import('./types').AnalyticsDataSource = { - getMembershipTrend: async (_signal?: AbortSignal) => { - await initPromise; - return getMemberGrowth(); - }, - getRoleDistribution: async (_signal?: AbortSignal) => { - await initPromise; - const state = getCommunityState(this.communityId); - const members = Object.values(state.memberStore); - const ALL_ROLES: import('./types').Role[] = ['member', 'moderator', 'admin']; - return ALL_ROLES.map(role => ({ - role, - count: members.filter(m => m.roles.includes(role)).length - })); - }, - getAccessAttempts: async (_signal?: AbortSignal) => { - await initPromise; - return getResourceAccess(); - } - } -} - +} \ No newline at end of file diff --git a/lib/api/mock/analytics.ts b/lib/api/mock/analytics.ts new file mode 100644 index 0000000..5482779 --- /dev/null +++ b/lib/api/mock/analytics.ts @@ -0,0 +1,80 @@ +/** + * lib/api/mock/analytics.ts + * + * Analytics domain of the mock API: the live admin analytics summary + * endpoint (computed from the in-memory store) and the AnalyticsDataSource + * built on the analytics fixtures. Extracted from lib/api/mock.ts. + */ +import { getMemberGrowth, getResourceAccess } from '../analytics/mock' +import { + getCommunityState, + initPromise, + type MockApiContext, +} from './state' +import type { + AnalyticsDataSource, + AnalyticsSummary, + Role, +} from '../types' + +export async function mockGetAnalyticsSummary( + ctx: MockApiContext, + _signal?: AbortSignal, +): Promise { + await initPromise + const state = getCommunityState(ctx.communityId) + const activeCount = Object.values(state.memberStore).filter(m => m.membership.active).length + const totalCount = Object.values(state.memberStore).length + const resourceAccess = state.resources.map(r => ({ + resourceId: r.id, + resourceTitle: r.title, + accessCount: Math.floor(Math.random() * 100) + 10, + deniedCount: Math.floor(Math.random() * 20), + })) + const summary: AnalyticsSummary = { + totalMembers: totalCount, + activeMembers: activeCount, + memberGrowth: Array.from({ length: 30 }, (_, i) => { + const d = new Date() + d.setDate(d.getDate() - (29 - i)) + return { + date: d.toISOString().split('T')[0], + newMembers: Math.floor(Math.random() * 3), + totalMembers: totalCount - (29 - i) * 2, + } + }), + resourceAccess, + generatedAt: new Date().toISOString(), + } + return new Promise((resolve) => + setTimeout(() => resolve(summary), 300), + ) +} + +/** + * Build the AnalyticsDataSource surface for a MockAccessApi instance. + * The three accessors read from the analytics fixture module, with role + * distribution computed live from the community's member store. + */ +export function buildAnalyticsDataSource(ctx: MockApiContext): AnalyticsDataSource { + return { + getMembershipTrend: async (_signal?: AbortSignal) => { + await initPromise; + return getMemberGrowth(); + }, + getRoleDistribution: async (_signal?: AbortSignal) => { + await initPromise; + const state = getCommunityState(ctx.communityId); + const members = Object.values(state.memberStore); + const ALL_ROLES: Role[] = ['member', 'moderator', 'admin']; + return ALL_ROLES.map(role => ({ + role, + count: members.filter(m => m.roles.includes(role)).length + })); + }, + getAccessAttempts: async (_signal?: AbortSignal) => { + await initPromise; + return getResourceAccess(); + } + } +} \ No newline at end of file diff --git a/lib/api/mock/approvals.ts b/lib/api/mock/approvals.ts new file mode 100644 index 0000000..a9e0367 --- /dev/null +++ b/lib/api/mock/approvals.ts @@ -0,0 +1,195 @@ +/** + * lib/api/mock/approvals.ts + * + * Admin mutation domain of the mock API: role/policy mutations with the + * multi-approval pending-action flow. Extracted from lib/api/mock.ts. + */ +import { PolicyValidationError, validatePolicy } from '../../validation/policy' +import { ApiError } from '../errors' +import { + getMockRoleMutationFailure, + throwMockRoleMutationFailure, +} from './controls' +import { MOCK_SESSION_STATE, throwMockUnauthorized } from './session' +import { + ensureAddress, + getCommunityState, + initPromise, + schedulePersist, + type MockApiContext, +} from './state' +import type { + AccessPolicy, + ApprovalConfig, + PendingAction, + PendingActionPayload, + PendingActionType, + Role, +} from '../types' + +export async function mockGetPendingActions(ctx: MockApiContext): Promise { + await initPromise + return getCommunityState(ctx.communityId).pendingActions +} + +export async function mockApproveAction(ctx: MockApiContext, id: string): Promise { + await initPromise + const state = getCommunityState(ctx.communityId) + const action = state.pendingActions.find(a => a.id === id) + if (!action || action.status !== 'pending') return + + const adminAddr = ctx.address || '0x0000000000000000000000000000000000000001' + if (!action.currentApprovals.includes(adminAddr)) { + action.currentApprovals.push(adminAddr) + } + + if (action.currentApprovals.length >= action.requiredApprovals) { + if (action.type === 'assignRole') { + const data = ensureAddress(action.payload.address!, ctx.communityId) + if (data && !data.roles.includes(action.payload.role! as Role)) data.roles.push(action.payload.role! as Role) + } else if (action.type === 'removeRole') { + const data = state.memberStore[action.payload.address!] + if (data) data.roles = data.roles.filter(r => r !== action.payload.role!) + } else if (action.type === 'updatePolicy') { + const result = validatePolicy(action.payload.policy!) + if (result.valid) { + const idx = state.policies.findIndex(p => p.resourceId === result.value.resourceId) + const updatedPolicy = { ...result.value, updatedAt: new Date().toISOString() } + if (idx >= 0) state.policies[idx] = updatedPolicy + else state.policies.push(updatedPolicy) + } + } + action.status = 'executed' + } + schedulePersist() +} + +export async function mockRejectAction(ctx: MockApiContext, id: string): Promise { + await initPromise + const state = getCommunityState(ctx.communityId) + const action = state.pendingActions.find(a => a.id === id) + if (action && action.status === 'pending') { + action.status = 'rejected' + schedulePersist() + } +} + +export async function mockUpdateApprovalConfig(ctx: MockApiContext, config: ApprovalConfig): Promise { + await initPromise + const state = getCommunityState(ctx.communityId) + ;(state.community as any).approvalConfig = config + schedulePersist() +} + +function checkApproval( + ctx: MockApiContext, + type: PendingActionType, + payload: PendingActionPayload, +): { status: 'executed' | 'pending'; pendingActionId?: string } { + const state = getCommunityState(ctx.communityId) + const config = (state.community as any).approvalConfig + const required = config ? config[type] || 1 : 1 + + if (required > 1) { + const pendingActionId = `pa_${Date.now()}_${Math.random().toString(36).substr(2, 5)}` + const adminAddr = ctx.address || '0x0000000000000000000000000000000000000001' + state.pendingActions.push({ + id: pendingActionId, + type, + payload, + proposer: adminAddr, + requiredApprovals: required, + currentApprovals: [adminAddr], + status: 'pending', + createdAt: new Date().toISOString() + }) + schedulePersist() + return { status: 'pending', pendingActionId } + } + return { status: 'executed' } +} + +export async function mockAssignRole( + ctx: MockApiContext, + address: string, + role: Role, +): Promise<{ status: 'executed' | 'pending'; pendingActionId?: string }> { + await initPromise + if (MOCK_SESSION_STATE === 'expired') throwMockUnauthorized() + if (getMockRoleMutationFailure()) throwMockRoleMutationFailure() + + const check = checkApproval(ctx, 'assignRole', { address, role }) + if (check.status === 'pending') return check + + const data = ensureAddress(address, ctx.communityId) + if (!data) return { status: 'executed' } + if (!data.roles.includes(role)) data.roles.push(role) + schedulePersist() + return { status: 'executed' } +} + +export async function mockRemoveRole( + ctx: MockApiContext, + address: string, + role: Role, +): Promise<{ status: 'executed' | 'pending'; pendingActionId?: string }> { + await initPromise + if (MOCK_SESSION_STATE === 'expired') throwMockUnauthorized() + if (getMockRoleMutationFailure()) throwMockRoleMutationFailure() + + const check = checkApproval(ctx, 'removeRole', { address, role }) + if (check.status === 'pending') return check + + const state = getCommunityState(ctx.communityId) + const data = state.memberStore[address] + if (!data) return { status: 'executed' } + data.roles = data.roles.filter((r) => r !== role) + schedulePersist() + return { status: 'executed' } +} + +export async function mockUpdatePolicy( + ctx: MockApiContext, + policy: AccessPolicy, +): Promise<{ status: 'executed' | 'pending'; pendingActionId?: string }> { + await initPromise + if (MOCK_SESSION_STATE === 'expired') throwMockUnauthorized() + const result = validatePolicy(policy) + + if (!result.valid) { + throw new PolicyValidationError(result.errors) + } + + const state = getCommunityState(ctx.communityId) + const idx = state.policies.findIndex((p) => p.resourceId === result.value.resourceId) + + // Optimistic concurrency control: check if policy was modified since load + if (idx >= 0 && policy.updatedAt) { + const existingPolicy = state.policies[idx] + if (existingPolicy.updatedAt && existingPolicy.updatedAt !== policy.updatedAt) { + throw new ApiError({ + status: 409, + code: 'conflict', + safeMessage: 'This policy was modified by another user. Please reload and try again.', + details: { + currentUpdatedAt: existingPolicy.updatedAt, + providedUpdatedAt: policy.updatedAt, + }, + }) + } + } + + const check = checkApproval(ctx, 'updatePolicy', { policy }) + if (check.status === 'pending') return check + + // Update policy with new timestamp + const updatedPolicy = { + ...result.value, + updatedAt: new Date().toISOString(), + } + + if (idx >= 0) state.policies[idx] = updatedPolicy + else state.policies.push(updatedPolicy) + schedulePersist() + return { status: 'executed' } +} diff --git a/lib/api/mock/controls.ts b/lib/api/mock/controls.ts new file mode 100644 index 0000000..c299d40 --- /dev/null +++ b/lib/api/mock/controls.ts @@ -0,0 +1,116 @@ +/** + * lib/api/mock/controls.ts + * + * Developer/test knobs for the mock API: simulated role-mutation failures, + * simulated resource-fetch failures/delays, and the advertised API-contract + * version override. Extracted from lib/api/mock.ts. + */ +import { ApiError } from '../errors' + +/** + * When true, the next assignRole()/removeRole() call throws a generic + * (non-auth) failure instead of succeeding β€” issue #243. This exists + * alongside NEXT_PUBLIC_MOCK_SESSION_STATE=expired rather than reusing it: + * that flag is read once at module load and specifically simulates auth/ + * session state, whereas this is a runtime-togglable flag for exercising + * the optimistic-update rollback path for an ordinary server error, from + * either a test or the /developer dev-tools page. Reset by resetMockData(). + */ +let mockRoleMutationShouldFail = false + +/** + * When set, the next getResource()/getPolicy() call(s) simulate an + * operational failure instead of succeeding β€” used to verify loading and + * error-boundary behaviour in mock mode without a real backend. + * 'network' simulates a transport-level failure (fetch rejection); + * 'server' simulates an HTTP 5xx. Reset by resetMockData(). + */ +let mockResourceFetchFailure: 'network' | 'server' | false = false + +/** Optional artificial delay (ms) applied before getResource()/getPolicy() resolve or fail. */ +let mockResourceFetchDelayMs = 0 + +/** + * Override the mock backend's advertised API contract version. + * Set to `null` to restore the default (matches EXPECTED_API_VERSION). + * When set, `getMeta()` returns this version, which can be used to + * simulate an incompatible backend. + */ +export let MOCK_META_VERSION_OVERRIDE: string | null = null + +export function getMockRoleMutationFailure(): boolean { + return mockRoleMutationShouldFail +} + +export function getMockResourceFetchFailure(): 'network' | 'server' | false { + return mockResourceFetchFailure +} + +export function getMockResourceFetchDelayMs(): number { + return mockResourceFetchDelayMs +} + +/** + * Toggle a simulated non-auth failure for the next assignRole()/ + * removeRole() call(s). Mock-only β€” LiveAccessApi has no equivalent, and + * this must never be called from application code, only from tests or the + * /developer page. + */ +export function setMockRoleMutationFailure(shouldFail: boolean): void { + mockRoleMutationShouldFail = shouldFail +} + +/** + * Toggle a simulated operational failure for getResource()/getPolicy(). + * Mock-only β€” LiveAccessApi has no equivalent. Intended for tests and the + * /developer page, never application code. + */ +export function setMockResourceFetchFailure(mode: 'network' | 'server' | false): void { + mockResourceFetchFailure = mode +} + +/** Set an artificial delay (ms) before getResource()/getPolicy() settle. Pass 0 to disable. */ +export function setMockResourceFetchDelay(ms: number): void { + mockResourceFetchDelayMs = ms +} + +/** + * Set the mock backend's advertised API contract version. Pass `null` to + * restore the default behaviour (matches the frontend's expected version). + */ +export function setMockMetaVersion(version: string | null): void { + MOCK_META_VERSION_OVERRIDE = version +} + +/** Build the simulated operational error used by getResource()/getPolicy(). */ +export function mockResourceFetchError(): ApiError { + return mockResourceFetchFailure === 'network' + ? new ApiError({ + code: 'network_error', + safeMessage: 'Unable to connect. Please check your connection and try again.', + retryable: true, + }) + : new ApiError({ + status: 500, + code: 'server_error', + safeMessage: 'The server could not complete the request. Please try again.', + retryable: true, + }) +} + +/** Throw a mock 500 ApiError β€” simulates an ordinary (non-auth) server failure. */ +export function throwMockRoleMutationFailure(): never { + throw new ApiError({ + status: 500, + code: 'server_error', + safeMessage: 'Simulated role mutation failure (mock mode).', + retryable: true, + }) +} + +/** Reset all fault-injection knobs to their defaults (called by resetMockData). */ +export function resetMockControls(): void { + mockRoleMutationShouldFail = false + mockResourceFetchFailure = false + mockResourceFetchDelayMs = 0 +} \ No newline at end of file diff --git a/lib/api/mock/core.ts b/lib/api/mock/core.ts new file mode 100644 index 0000000..b67f743 --- /dev/null +++ b/lib/api/mock/core.ts @@ -0,0 +1,93 @@ +/** + * lib/api/mock/core.ts + * + * Core read paths of the mock API: meta/version, community, resource and + * policy lookups, and wallet verification. Extracted from lib/api/mock.ts. + */ +import { + MOCK_META_VERSION_OVERRIDE, + getMockResourceFetchDelayMs, + getMockResourceFetchFailure, + mockResourceFetchError, +} from './controls' +import { + getCommunityState, + initPromise, + type MockApiContext, +} from './state' +import { EXPECTED_API_VERSION } from '../types' +import type { + AccessPolicy, + Community, + MetaResponse, + Resource, + ResourceLookupResult, + WalletVerification, +} from '../types' + +export async function mockGetMeta(_signal?: AbortSignal): Promise { + await initPromise + return { + version: MOCK_META_VERSION_OVERRIDE ?? EXPECTED_API_VERSION, + commit: 'mock-commit-sha', + uptime: (typeof process !== 'undefined' && typeof process.uptime === 'function') ? Math.floor(process.uptime()) : 0, + } +} + +export async function mockGetCommunity(ctx: MockApiContext, _signal?: AbortSignal): Promise { + await initPromise + return getCommunityState(ctx.communityId).community +} + +export async function mockVerifyWallet(_address: string, _signal?: AbortSignal): Promise { + await initPromise + return { + verified: true, + method: 'mock', + checkedAt: new Date().toISOString(), + } +} + +export async function mockListResources(ctx: MockApiContext, _signal?: AbortSignal): Promise { + await initPromise + const state = getCommunityState(ctx.communityId) + return state.resources.map((r) => ({ ...r, roles: r.roles ?? [] })) +} + +export async function mockListPolicies(ctx: MockApiContext, _signal?: AbortSignal): Promise { + await initPromise + const state = getCommunityState(ctx.communityId) + return state.policies.map((p) => ({ ...p, roles: p.roles ?? [] })) +} + +export async function mockGetResource( + ctx: MockApiContext, + id: string, + _signal?: AbortSignal, +): Promise { + await initPromise + if (getMockResourceFetchDelayMs() > 0) await new Promise((r) => setTimeout(r, getMockResourceFetchDelayMs())) + if (getMockResourceFetchFailure()) { + return { status: 'error', error: mockResourceFetchError() } + } + const state = getCommunityState(ctx.communityId) + const r = state.resources.find((x) => x.id === id) + return r + ? { status: 'found', data: { ...r, roles: r.roles ?? [] }, source: 'direct' } + : { status: 'not_found' } +} + +export async function mockGetPolicy( + ctx: MockApiContext, + resourceId: string, + _signal?: AbortSignal, +): Promise { + await initPromise + if (getMockResourceFetchDelayMs() > 0) await new Promise((r) => setTimeout(r, getMockResourceFetchDelayMs())) + if (getMockResourceFetchFailure()) { + throw mockResourceFetchError() + } + const state = getCommunityState(ctx.communityId) + const p = state.policies.find((x) => x.resourceId === resourceId) + return p ? { ...p, roles: p.roles ?? [] } : null +} \ No newline at end of file diff --git a/lib/api/mock/fixtures.ts b/lib/api/mock/fixtures.ts index 278c435..7be008f 100644 --- a/lib/api/mock/fixtures.ts +++ b/lib/api/mock/fixtures.ts @@ -273,6 +273,15 @@ export const MOCK_MEMBER_STORES: Record): void { + mockPrivacySettings = next +} + +/** Reassign the reports store (owning module only β€” see note above). */ +export function setMockReports(next: ModerationReport[]): void { + mockReports = next +} diff --git a/lib/api/mock/governance.ts b/lib/api/mock/governance.ts new file mode 100644 index 0000000..c8b3dda --- /dev/null +++ b/lib/api/mock/governance.ts @@ -0,0 +1,414 @@ +/** + * lib/api/mock/governance.ts + * + * Governance domain of the mock API: proposals and weighted voting. + * Extracted from lib/api/mock.ts. + */ +import { ApiError } from '../errors' +import { MOCK_SESSION_STATE, throwMockUnauthorized } from './session' +import { + getCommunityState, + initPromise, + schedulePersist, + type MockApiContext, +} from './state' +import type { + MembershipTier, + Proposal, + ProposalStatus, + ProposalType, + Role, + Vote, + VoteChoice, +} from '../types' + +export async function mockListProposals( + ctx: MockApiContext, + params?: { filter?: ProposalStatus | ProposalType; limit?: number; cursor?: string }, + _signal?: AbortSignal, +): Promise { + await initPromise + const state = getCommunityState(ctx.communityId) + let proposals = Object.values(state.proposals) + + if (params?.filter) { + // Filter by status or type + const isStatus = ['draft', 'active', 'closed', 'resolved'].includes(params.filter) + if (isStatus) { + proposals = proposals.filter(p => p.status === params.filter) + } else { + proposals = proposals.filter(p => p.type === params.filter) + } + } + + // Sort by creation date, newest first + proposals.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + + const limit = params?.limit ?? 20 + const cursor = params?.cursor ? parseInt(params.cursor, 10) : 0 + return proposals.slice(cursor, cursor + limit) +} + +export async function mockGetProposal( + ctx: MockApiContext, + id: string, + _signal?: AbortSignal, +): Promise { + await initPromise + const state = getCommunityState(ctx.communityId) + return state.proposals[id] ?? null +} + +export async function mockGetMemberVote( + ctx: MockApiContext, + proposalId: string, + _signal?: AbortSignal, +): Promise { + await initPromise + if (!ctx.address) return null + + const state = getCommunityState(ctx.communityId) + const vote = Object.values(state.votes).find( + v => v.proposalId === proposalId && v.voter.toLowerCase() === ctx.address!.toLowerCase() + ) + return vote ?? null +} + +export async function mockListProposalVotes( + ctx: MockApiContext, + proposalId: string, + params?: { limit?: number; cursor?: string }, + _signal?: AbortSignal, +): Promise { + await initPromise + const state = getCommunityState(ctx.communityId) + let votes = Object.values(state.votes).filter(v => v.proposalId === proposalId) + + // Sort by vote time, newest first + votes.sort((a, b) => new Date(b.votedAt).getTime() - new Date(a.votedAt).getTime()) + + const limit = params?.limit ?? 20 + const cursor = params?.cursor ? parseInt(params.cursor, 10) : 0 + return votes.slice(cursor, cursor + limit) +} + +export async function mockCastVote( + ctx: MockApiContext, + proposalId: string, + choice: VoteChoice, +): Promise { + await initPromise + if (MOCK_SESSION_STATE === 'expired') throwMockUnauthorized() + if (!ctx.address) { + throw new ApiError({ + status: 403, + code: 'forbidden', + safeMessage: 'Must be authenticated to vote.', + }) + } + + const state = getCommunityState(ctx.communityId) + const proposal = state.proposals[proposalId] + + if (!proposal) { + throw new ApiError({ + status: 404, + code: 'not_found', + safeMessage: 'Proposal not found.', + }) + } + + if (proposal.status !== 'active') { + throw new ApiError({ + status: 400, + code: 'invalid_state', + safeMessage: 'Voting is not currently open for this proposal.', + }) + } + + // Check if already voted + const existingVoteId = Object.entries(state.votes).find( + ([_, v]) => v.proposalId === proposalId && v.voter.toLowerCase() === ctx.address!.toLowerCase() + )?.[0] + + // Get voter's weight based on tier/role + const memberData = state.memberStore[ctx.address] + const tier = memberData?.membership.tier ?? 'free' + const role = memberData?.roles[0] ?? 'member' + + // Simple weight: free=1, standard=2, pro=3 (tier) Γ— member=1, moderator=2, admin=3 (role) + const tierWeight: Record = { free: 1, standard: 2, pro: 3 } + const roleMultiplier: Record = { member: 1, moderator: 2, admin: 3 } + const weight = tierWeight[tier] * roleMultiplier[role] + + const vote: Vote = { + id: existingVoteId || `vote_${Date.now()}_${Math.random().toString(16).slice(2)}`, + proposalId, + voter: ctx.address, + choice, + weight, + voterContext: { tier, role }, + votedAt: new Date().toISOString(), + } + + // Update proposal vote summary + if (existingVoteId) { + const oldVote = state.votes[existingVoteId] + // Remove old vote from summary + proposal.votesSummary.totalVotes-- + proposal.votesSummary.weightsFor -= oldVote.choice === 'for' ? oldVote.weight : 0 + proposal.votesSummary.weightsAgainst -= oldVote.choice === 'against' ? oldVote.weight : 0 + proposal.votesSummary.weightsAbstain -= oldVote.choice === 'abstain' ? oldVote.weight : 0 + } + + // Add new vote to summary + proposal.votesSummary.totalVotes++ + if (choice === 'for') proposal.votesSummary.weightsFor += weight + else if (choice === 'against') proposal.votesSummary.weightsAgainst += weight + else proposal.votesSummary.weightsAbstain += weight + + // Recalculate percentages + if (proposal.totalWeight > 0) { + proposal.votesSummary.percentFor = Math.round((proposal.votesSummary.weightsFor / proposal.totalWeight) * 100) + proposal.votesSummary.percentAgainst = Math.round((proposal.votesSummary.weightsAgainst / proposal.totalWeight) * 100) + } + + state.votes[vote.id] = vote + schedulePersist() + + return vote +} + +export async function mockCreateProposal( + ctx: MockApiContext, + proposal: Omit, +): Promise { + await initPromise + if (MOCK_SESSION_STATE === 'expired') throwMockUnauthorized() + + // Check if user is admin + if (!ctx.address || !getCommunityState(ctx.communityId).memberStore[ctx.address]?.roles.includes('admin')) { + throw new ApiError({ + status: 403, + code: 'forbidden', + safeMessage: 'Only admins can create proposals.', + }) + } + + const state = getCommunityState(ctx.communityId) + + // Calculate total weight (sum of all member weights) + let totalWeight = 0 + Object.values(state.memberStore).forEach(member => { + const tierWeight: Record = { free: 1, standard: 2, pro: 3 } + const roleMultiplier: Record = { member: 1, moderator: 2, admin: 3 } + const weight = tierWeight[member.membership.tier] * roleMultiplier[member.roles[0] ?? 'member'] + totalWeight += weight + }) + + const newProposal: Proposal = { + id: `prop_${Date.now()}_${Math.random().toString(16).slice(2)}`, + communityId: ctx.communityId, + ...proposal, + status: 'draft', + createdAt: new Date().toISOString(), + votesSummary: { + totalVotes: 0, + weightsFor: 0, + weightsAgainst: 0, + weightsAbstain: 0, + }, + totalWeight, + } + + state.proposals[newProposal.id] = newProposal + schedulePersist() + + return newProposal +} + +export async function mockUpdateProposal( + ctx: MockApiContext, + id: string, + updates: Partial>, +): Promise { + await initPromise + if (MOCK_SESSION_STATE === 'expired') throwMockUnauthorized() + + if (!ctx.address || !getCommunityState(ctx.communityId).memberStore[ctx.address]?.roles.includes('admin')) { + throw new ApiError({ + status: 403, + code: 'forbidden', + safeMessage: 'Only admins can update proposals.', + }) + } + + const state = getCommunityState(ctx.communityId) + const proposal = state.proposals[id] + + if (!proposal) { + throw new ApiError({ + status: 404, + code: 'not_found', + safeMessage: 'Proposal not found.', + }) + } + + if (proposal.status === 'active' || proposal.status === 'resolved') { + throw new ApiError({ + status: 400, + code: 'invalid_state', + safeMessage: 'Cannot update an active or resolved proposal.', + }) + } + + Object.assign(proposal, updates) + schedulePersist() + + return proposal +} + +export async function mockPublishProposal(ctx: MockApiContext, id: string): Promise { + await initPromise + if (MOCK_SESSION_STATE === 'expired') throwMockUnauthorized() + + if (!ctx.address || !getCommunityState(ctx.communityId).memberStore[ctx.address]?.roles.includes('admin')) { + throw new ApiError({ + status: 403, + code: 'forbidden', + safeMessage: 'Only admins can publish proposals.', + }) + } + + const state = getCommunityState(ctx.communityId) + const proposal = state.proposals[id] + + if (!proposal) { + throw new ApiError({ + status: 404, + code: 'not_found', + safeMessage: 'Proposal not found.', + }) + } + + if (proposal.status !== 'draft') { + throw new ApiError({ + status: 400, + code: 'invalid_state', + safeMessage: 'Only draft proposals can be published.', + }) + } + + proposal.status = 'active' + schedulePersist() + + return proposal +} + +export async function mockCloseProposalVoting(ctx: MockApiContext, id: string): Promise { + await initPromise + if (MOCK_SESSION_STATE === 'expired') throwMockUnauthorized() + + if (!ctx.address || !getCommunityState(ctx.communityId).memberStore[ctx.address]?.roles.includes('admin')) { + throw new ApiError({ + status: 403, + code: 'forbidden', + safeMessage: 'Only admins can close voting.', + }) + } + + const state = getCommunityState(ctx.communityId) + const proposal = state.proposals[id] + + if (!proposal) { + throw new ApiError({ + status: 404, + code: 'not_found', + safeMessage: 'Proposal not found.', + }) + } + + if (proposal.status !== 'active') { + throw new ApiError({ + status: 400, + code: 'invalid_state', + safeMessage: 'Only active proposals can be closed.', + }) + } + + proposal.status = 'closed' + schedulePersist() + + return proposal +} + +export async function mockResolveProposal(ctx: MockApiContext, id: string, outcome: string): Promise { + await initPromise + if (MOCK_SESSION_STATE === 'expired') throwMockUnauthorized() + + if (!ctx.address || !getCommunityState(ctx.communityId).memberStore[ctx.address]?.roles.includes('admin')) { + throw new ApiError({ + status: 403, + code: 'forbidden', + safeMessage: 'Only admins can resolve proposals.', + }) + } + + const state = getCommunityState(ctx.communityId) + const proposal = state.proposals[id] + + if (!proposal) { + throw new ApiError({ + status: 404, + code: 'not_found', + safeMessage: 'Proposal not found.', + }) + } + + proposal.status = 'resolved' + proposal.payload = { ...proposal.payload, outcome, resolvedAt: new Date().toISOString() } + schedulePersist() + + return proposal +} + +export async function mockDeleteProposal(ctx: MockApiContext, id: string): Promise { + await initPromise + if (MOCK_SESSION_STATE === 'expired') throwMockUnauthorized() + + if (!ctx.address || !getCommunityState(ctx.communityId).memberStore[ctx.address]?.roles.includes('admin')) { + throw new ApiError({ + status: 403, + code: 'forbidden', + safeMessage: 'Only admins can delete proposals.', + }) + } + + const state = getCommunityState(ctx.communityId) + const proposal = state.proposals[id] + + if (!proposal) { + throw new ApiError({ + status: 404, + code: 'not_found', + safeMessage: 'Proposal not found.', + }) + } + + if (proposal.status !== 'draft') { + throw new ApiError({ + status: 400, + code: 'invalid_state', + safeMessage: 'Only draft proposals can be deleted.', + }) + } + + delete state.proposals[id] + // Also delete any votes on this proposal + Object.keys(state.votes).forEach(voteId => { + if (state.votes[voteId].proposalId === id) { + delete state.votes[voteId] + } + }) + schedulePersist() +} \ No newline at end of file diff --git a/lib/api/mock/members.ts b/lib/api/mock/members.ts new file mode 100644 index 0000000..186ce33 --- /dev/null +++ b/lib/api/mock/members.ts @@ -0,0 +1,122 @@ +/** + * lib/api/mock/members.ts + * + * Member-domain read paths and the self-service profile mutation for the + * mock API. Extracted from lib/api/mock.ts. + */ +import { ProfileValidationError, validateProfile } from '../../validation/profile' +import { ApiError } from '../errors' +import { MOCK_SESSION_STATE, throwMockUnauthorized } from './session' +import { + ensureAddress, + getCommunityState, + initPromise, + schedulePersist, + type MockApiContext, +} from './state' +import type { + MemberProfile, + MemberRow, + Membership, + PaginatedMembers, +} from '../types' + +export async function mockGetMembership( + ctx: MockApiContext, + address: string, + _signal?: AbortSignal, +): Promise { + await initPromise + const data = ensureAddress(address, ctx.communityId) + return data?.membership ?? null +} + +export async function mockGetProfile( + ctx: MockApiContext, + address: string, + _signal?: AbortSignal, +): Promise { + await initPromise + const data = ensureAddress(address, ctx.communityId) + return data?.profile ?? null +} + +/** + * Updates the caller's own profile. Mirrors the live client's self-service + * ownership check (`ctx.address` must match `profile.address`) even + * though mock mode has no real signature to verify, so the two clients + * behave the same way from a caller's perspective. `badges` is + * system-assigned and is always preserved from the existing record, + * regardless of what the caller passes. + */ +export async function mockUpdateProfile(ctx: MockApiContext, profile: MemberProfile): Promise { + await initPromise + if (MOCK_SESSION_STATE === 'expired') throwMockUnauthorized() + + if (!ctx.address || ctx.address.toLowerCase() !== profile.address?.toLowerCase()) { + throw new ApiError({ + status: 403, + code: 'forbidden', + safeMessage: 'You can only edit your own profile.', + }) + } + + const result = validateProfile(profile) + if (!result.valid) { + throw new ProfileValidationError(result.errors) + } + + const data = ensureAddress(result.value.address, ctx.communityId) + if (!data) { + throw new ApiError({ + status: 404, + code: 'not_found', + safeMessage: `Member "${result.value.address}" not found.`, + }) + } + + data.profile = { + ...data.profile, + displayName: result.value.displayName, + bio: result.value.bio, + avatar: result.value.avatar, + socialLinks: result.value.socialLinks, + } + schedulePersist() +} + +export async function mockListMembers( + ctx: MockApiContext, + params?: { cursor?: string; limit?: number; filter?: string }, + _signal?: AbortSignal, +): Promise { + await initPromise + const state = getCommunityState(ctx.communityId) + let list = Object.values(state.memberStore).map((m) => ({ + address: m.membership.address, + roles: m.roles, + tier: m.membership.tier, + active: m.membership.active, + ...(m.profile.displayName ? { displayName: m.profile.displayName } : {}), + })) + + if (!params) { + return list + } + + if (params.filter) { + const f = params.filter.toLowerCase() + list = list.filter((m) => m.address.toLowerCase().includes(f)) + } + + const limit = params.limit ?? 100 + const cursor = params.cursor ? parseInt(params.cursor, 10) : 0 + + const paginated = list.slice(cursor, cursor + limit) + const nextCursor = cursor + limit < list.length ? String(cursor + limit) : undefined + + return { + members: paginated, + nextCursor, + } +} \ No newline at end of file diff --git a/lib/api/mock/moderation.ts b/lib/api/mock/moderation.ts new file mode 100644 index 0000000..77ef2de --- /dev/null +++ b/lib/api/mock/moderation.ts @@ -0,0 +1,35 @@ +/** + * lib/api/mock/moderation.ts + * + * Moderation domain of the mock API: the report queue and report-state + * transitions. Extracted from lib/api/mock.ts. + */ +import { mockReports } from './fixtures' +import { initPromise } from './state' +import type { ModerationReport, ModerationState } from '../types' + +export async function mockListReports(_signal?: AbortSignal): Promise { + await initPromise + return mockReports +} + +export async function mockGetReport(id: string, _signal?: AbortSignal): Promise { + await initPromise + return mockReports.find(r => r.id === id) || null +} + +export async function mockUpdateReportState( + 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() + } +} \ No newline at end of file diff --git a/lib/api/mock/scenarios.ts b/lib/api/mock/scenarios.ts new file mode 100644 index 0000000..cb4319a --- /dev/null +++ b/lib/api/mock/scenarios.ts @@ -0,0 +1,243 @@ +/** + * lib/api/mock/scenarios.ts + * + * Developer-testing controls for the mock API: scenario presets and the + * full mock data reset. Extracted from lib/api/mock.ts. + */ +import { clearPersistedState } from '../mock-storage' +import { resetMockControls } from './controls' +import { + getCommunityState, + initPromise, + resetCommunityStates, + schedulePersist, +} from './state' + +export type MockScenario = + | 'active-member' + | 'expired-member' + | 'denied-resource' + | 'admin-session-expired' + | 'no-roles' + | 'multiple-roles' + | 'multiple-communities' + | 'concurrent-policy-edit' + | 'customized-profile' + +/** + * Reset all mock data to its initial state. + */ +export async function resetMockData() { + await initPromise + resetCommunityStates() + resetMockControls() + await clearPersistedState() +} + +/** + * Apply a predefined scenario preset for testing. + */ +export async function applyMockScenario(scenario: MockScenario, address: string = '0x1234567890123456789012345678901234567890') { + await resetMockData() + + const demoState = getCommunityState('guildpass-demo') + + switch (scenario) { + case 'active-member': + demoState.memberStore[address] = { + membership: { + address, + tier: 'standard', + active: true, + }, + roles: ['member'], + profile: { + address, + displayName: 'Active Standard User', + badges: ['Early Member', 'Standard Tier'], + }, + } + break + + case 'expired-member': + demoState.memberStore[address] = { + membership: { + address, + tier: 'standard', + active: false, + expiresAt: new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(), + }, + roles: ['member'], + profile: { + address, + displayName: 'Expired User', + badges: ['Former Member'], + }, + } + break + + case 'denied-resource': + demoState.memberStore[address] = { + membership: { + address, + tier: 'free', + active: true, + }, + roles: ['member'], + profile: { + address, + displayName: 'Free Tier User', + badges: ['Free Tier'], + }, + } + // Ensure Alpha Docs require standard tier + demoState.policies = demoState.policies.map(p => + p.resourceId === 'alpha' + ? { ...p, minTier: 'standard' } + : p + ) + break + + case 'admin-session-expired': + demoState.memberStore[address] = { + membership: { + address, + tier: 'pro', + active: true, + }, + roles: ['admin', 'member'], + profile: { + address, + displayName: 'Expired Admin', + badges: ['Admin', 'Pro Tier'], + }, + } + break + + case 'no-roles': + demoState.memberStore[address] = { + membership: { + address, + tier: 'free', + active: true, + }, + roles: [], + profile: { + address, + displayName: 'No Roles User', + badges: ['New User'], + }, + } + break + + case 'multiple-roles': + // 'admin' is included deliberately: it's the only role that changes + // nav/admin-console visibility in this codebase (every first-party + // module in lib/admin-modules/modules/*.ts requires it), so leaving it + // out would make role-aware nav unverifiable. It does not bypass + // tier-gated resource access (lib/api/access-decision.ts evaluates + // tier independently of role), so alpha/pro-reports stay genuinely + // tier-gated for this member. + demoState.memberStore[address] = { + membership: { + address, + tier: 'pro', + active: true, + }, + roles: ['admin', 'moderator', 'member'], + profile: { + address, + displayName: 'Multi-Role Member', + badges: ['Admin', 'Moderator'], + }, + } + break + + case 'multiple-communities': + // Seed a member whose data reflects participation in more than one + // community. The mock session model exposes a single active community, + // so this preset points the active community at a multi-community hub + // and marks the member's badges to reflect their other memberships. + // Existing single-community presets are unaffected. + const hubState = getCommunityState('guildpass-hub') + hubState.community = { + id: 'guildpass-hub', + name: 'GuildPass Hub (Multi-Community)', + description: + 'Shared hub for a member active across several communities', + tiers: ['free', 'standard', 'pro'], + } + hubState.memberStore[address] = { + membership: { + address, + tier: 'standard', + active: true, + }, + roles: ['member'], + profile: { + address, + displayName: 'Multi-Community Member', + badges: [ + 'GuildPass Demo Community', + 'Builders Collective', + 'Design Guild', + ], + }, + } + break + + case 'concurrent-policy-edit': + // Set up a scenario to test concurrent policy editing + demoState.memberStore[address] = { + membership: { + address, + tier: 'pro', + active: true, + }, + roles: ['admin', 'member'], + profile: { + address, + displayName: 'Admin Testing Concurrency', + badges: ['Admin', 'Pro Tier'], + }, + } + // Update the 'alpha' policy with a very recent timestamp to simulate + // another admin just having edited it + const alphaIdx = demoState.policies.findIndex((p) => p.resourceId === 'alpha') + if (alphaIdx >= 0) { + demoState.policies[alphaIdx] = { + ...demoState.policies[alphaIdx], + updatedAt: new Date(Date.now() - 1000 * 5).toISOString(), // 5 seconds ago + minTier: 'pro', // Changed from 'standard' + } + } + break + + case 'customized-profile': + // A member who has filled out every rich-profile field (#254), to + // exercise the public profile view and editor pre-fill against a + // fully-populated record rather than only the sparse defaults. + demoState.memberStore[address] = { + membership: { + address, + tier: 'standard', + active: true, + }, + roles: ['member'], + profile: { + address, + displayName: 'Ada Lovelace', + bio: 'Builder and early GuildPass member, exploring what token-gated communities can look like.', + avatar: 'https://example.com/avatars/ada-lovelace.png', + socialLinks: [ + { platform: 'twitter', url: 'https://example.com/twitter/ada' }, + { platform: 'github', url: 'https://example.com/github/ada' }, + { platform: 'website', url: 'https://example.com/ada' }, + ], + badges: ['Early Member', 'Standard Tier'], + }, + } + break + } + schedulePersist() +} \ No newline at end of file diff --git a/lib/api/mock/session.ts b/lib/api/mock/session.ts new file mode 100644 index 0000000..ee76c27 --- /dev/null +++ b/lib/api/mock/session.ts @@ -0,0 +1,259 @@ +/** + * lib/api/mock/session.ts + * + * Mock session & SIWE simulation: the cookie-session simulation helpers, + * nonce handling, and the SIWE endpoints plus the member session read. + * Extracted from lib/api/mock.ts. + * + * Session simulation: + * Set NEXT_PUBLIC_MOCK_SESSION_STATE to control the simulated auth boundary: + * "expired" β€” siweVerify returns an already-expired access token + * with a valid refresh token so renewal can be tested + * "unauthenticated" β€” siweVerify always throws, simulating a backend rejection + * (default) β€” normal mock behaviour (instant auth, 1-hour token) + */ +import { ApiError } from '../errors' +import type { + Session, + SessionStatus, + SiweAuthSession, +} from '../types' +import { + ensureAddress, + getCommunityState, + initPromise, + type MockApiContext, +} from './state' + +/** Read once at module load so it is stable across renders. */ +export const MOCK_SESSION_STATE = + (typeof process !== 'undefined' && + process.env.NEXT_PUBLIC_MOCK_SESSION_STATE) || + '' + +// ── Mock cookie-session simulation (cookie auth mode) ─────────────────────── +// +// There is no real backend in mock mode, so a real httpOnly cookie can't be +// set. This uses a plain, non-httpOnly document.cookie entry to simulate +// "the browser is holding a session cookie" β€” an honest simulation boundary +// (mock JS genuinely cannot set an httpOnly cookie either). It intentionally +// never touches sessionStorage, so cookie-mode session state is provably +// independent of the bearer-token sessionStorage path in lib/session.ts. +// Only ever written/read when the caller is in cookie auth mode, so +// bearer-mode mock runs get zero new side effects. + +const MOCK_SESSION_COOKIE = 'gp_mock_session' + +function setMockSessionCookie(address: string, expiresAt: string): void { + if (typeof document === 'undefined') return + const value = encodeURIComponent(`${address}|${expiresAt}`) + document.cookie = `${MOCK_SESSION_COOKIE}=${value}; path=/; SameSite=Lax` +} + +function clearMockSessionCookie(): void { + if (typeof document === 'undefined') return + document.cookie = `${MOCK_SESSION_COOKIE}=; path=/; Max-Age=0; SameSite=Lax` +} + +function readMockSessionCookie(): { address: string; expiresAt: string } | null { + if (typeof document === 'undefined') return null + const row = document.cookie + .split('; ') + .find((entry) => entry.startsWith(`${MOCK_SESSION_COOKIE}=`)) + if (!row) return null + const raw = decodeURIComponent(row.slice(MOCK_SESSION_COOKIE.length + 1)) + const [address, expiresAt] = raw.split('|') + return address && expiresAt ? { address, expiresAt } : null +} + +/** Nonce TTL in milliseconds (5 minutes β€” mirrors siwe-go default). */ +const NONCE_TTL_MS = 5 * 60 * 1000 + +/** Extract the nonce value from an EIP-4361 message string. */ +function extractNonceFromMessage(message: string): string | null { + const match = message.match(/Nonce:\s*(\S+)/) + return match ? match[1] : null +} + +/** Generate a short random hex nonce (16 bytes). */ +function randomHex(): string { + return Array.from({ length: 16 }, () => + Math.floor(Math.random() * 256) + .toString(16) + .padStart(2, '0'), + ).join('') +} + +/** Throw a mock 401 ApiError β€” mirrors what the live API throws on expired tokens. */ +export function throwMockUnauthorized(): never { + throw new ApiError({ + status: 401, + code: 'unauthorized', + safeMessage: 'Session expired. Please sign in again.', + }) +} + +/** Member session read: resolves the caller's session for the community. */ +export async function mockGetSession(ctx: MockApiContext, _signal?: AbortSignal): Promise { + await initPromise + const MOCK_SESSION_STATE = process.env.NEXT_PUBLIC_MOCK_SESSION_STATE || 'valid' + const state = getCommunityState(ctx.communityId) + if (MOCK_SESSION_STATE === 'cleared') { + return { + // No authenticated session + roles: [], + community: state.community, + } + } + + const data = ensureAddress(ctx.address, ctx.communityId) + return { + address: ctx.address, + roles: data ? data.roles : [], + membership: data ? data.membership : undefined, + community: state.community, + ...(data ? { badges: data.profile.badges } : {}), + } +} + +export async function mockGetNonce(ctx: MockApiContext, nonceStore: Map, address: string): Promise { + await initPromise + const nonce = randomHex() + nonceStore.set(nonce, Date.now()) + return nonce +} + +export async function mockSiweVerify( + ctx: MockApiContext, + nonceStore: Map, + message: string, + _signature: string, +): Promise { + await initPromise + if (MOCK_SESSION_STATE === 'unauthenticated') { + throwMockUnauthorized() + } + + const nonce = extractNonceFromMessage(message) + if (!nonce || !nonceStore.has(nonce)) { + throw new ApiError({ + status: 400, + code: 'bad_request', + safeMessage: 'Nonce not found or already used.', + }) + } + + const createdAt = nonceStore.get(nonce)! + if (Date.now() - createdAt > NONCE_TTL_MS) { + nonceStore.delete(nonce) + throw new ApiError({ + status: 400, + code: 'bad_request', + safeMessage: 'Nonce expired. Please request a new one.', + }) + } + + nonceStore.delete(nonce) + + const expiresAt = + MOCK_SESSION_STATE === 'expired' + ? new Date(Date.now() - 1).toISOString() + : new Date(Date.now() + 60 * 60 * 1000).toISOString() + + const refreshExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString() + const resolvedAddress = ctx.address ?? '0x0000000000000000000000000000000000000000' + + if (ctx.authMode === 'cookie') { + setMockSessionCookie(resolvedAddress, expiresAt) + } + + return { + isAuthenticated: true, + token: `mock-jwt-${randomHex()}`, + address: resolvedAddress, + expiresAt, + refreshToken: `mock-refresh-${randomHex()}`, + refreshExpiresAt, + } +} + +export async function mockSiweRefresh(ctx: MockApiContext, refreshToken: string): Promise { + await initPromise + // e2e instrumentation only: mock mode makes no real network request for + // siweRefresh, so cross-tab race tests need some observable signal for + // "how many refresh attempts actually happened" per tab. + if (typeof window !== 'undefined') { + (window as any).__mockSiweRefreshCalls__ = + ((window as any).__mockSiweRefreshCalls__ ?? 0) + 1 + } + if (MOCK_SESSION_STATE === 'expired' || MOCK_SESSION_STATE === 'unauthenticated') { + throw new ApiError({ + status: 401, + code: 'unauthorized', + safeMessage: 'Refresh token expired. Please sign in again.', + }) + } + + if (ctx.authMode === 'cookie') { + // Cookie mode has no refresh-token string for the frontend to hold β€” + // the (mock) session cookie is the only refreshability signal. + if (!readMockSessionCookie()) { + throw new ApiError({ + status: 401, + code: 'unauthorized', + safeMessage: 'Invalid refresh token.', + }) + } + } else if (!refreshToken || !refreshToken.startsWith('mock-refresh-')) { + throw new ApiError({ + status: 401, + code: 'unauthorized', + safeMessage: 'Invalid refresh token.', + }) + } + + const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString() + const refreshExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString() + const resolvedAddress = ctx.address ?? '0x0000000000000000000000000000000000000000' + + if (ctx.authMode === 'cookie') { + setMockSessionCookie(resolvedAddress, expiresAt) + } + + return { + isAuthenticated: true, + token: `mock-jwt-${randomHex()}`, + address: resolvedAddress, + expiresAt, + refreshToken: `mock-refresh-${randomHex()}`, + refreshExpiresAt, + } +} + +export async function mockSiweLogout(ctx: MockApiContext, _token?: string): Promise { + await initPromise + if (ctx.authMode === 'cookie') { + clearMockSessionCookie() + } +} + +/** + * Mock counterpart to LiveAccessApi.getSessionStatus(). Reads only the + * simulated document.cookie session marker set by siweVerify/siweRefresh β€” + * never sessionStorage β€” so cookie-mode session state stays deterministic + * and independent of the bearer-token sessionStorage path. + */ +export async function mockGetSessionStatus(ctx: MockApiContext, _signal?: AbortSignal): Promise { + await initPromise + if (MOCK_SESSION_STATE === 'unauthenticated') { + return { authenticated: false } + } + const cookie = readMockSessionCookie() + if (!cookie) { + return { authenticated: false } + } + if (MOCK_SESSION_STATE === 'expired' || new Date(cookie.expiresAt).getTime() <= Date.now()) { + return { authenticated: false } + } + return { authenticated: true, address: cookie.address, expiresAt: cookie.expiresAt } +} \ No newline at end of file diff --git a/lib/api/mock/social.ts b/lib/api/mock/social.ts new file mode 100644 index 0000000..8a3dc03 --- /dev/null +++ b/lib/api/mock/social.ts @@ -0,0 +1,153 @@ +/** + * lib/api/mock/social.ts + * + * Social-graph domain of the mock API: connection requests, privacy + * settings, and blocking. Extracted from lib/api/mock.ts. + */ +import { + mockConnections, + mockPrivacySettings, + setMockConnections, +} from './fixtures' +import { initPromise, type MockApiContext } from './state' +import type { Connection, MemberPrivacySettings } from '../types' + +export async function mockGetConnections( + ctx: MockApiContext, + address: string, + _signal?: AbortSignal, +): Promise { + await initPromise + const addr = address.toLowerCase() + const viewer = ctx.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) + ) +} + +export async function mockGetPrivacySettings( + ctx: MockApiContext, + address: string, + _signal?: AbortSignal, +): Promise { + await initPromise + const addr = address.toLowerCase() + return mockPrivacySettings[addr] || { address, connectionVisibility: 'public' } +} + +export async function mockUpdatePrivacySettings( + ctx: MockApiContext, + address: string, + settings: MemberPrivacySettings, +): Promise { + await initPromise + const addr = address.toLowerCase() + mockPrivacySettings[addr] = settings +} + +export async function mockBlockMember(ctx: MockApiContext, targetAddress: string): Promise { + await initPromise + if (!ctx.address) throw new Error('Not logged in') + const viewer = ctx.address.toLowerCase() + const target = targetAddress.toLowerCase() + + // Remove existing connections between them + setMockConnections(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: ctx.address, + toAddress: targetAddress, + status: 'blocked', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }) +} + +export async function mockUnblockMember(ctx: MockApiContext, targetAddress: string): Promise { + await initPromise + if (!ctx.address) throw new Error('Not logged in') + const viewer = ctx.address.toLowerCase() + const target = targetAddress.toLowerCase() + + setMockConnections(mockConnections.filter(c => + !(c.status === 'blocked' && c.fromAddress.toLowerCase() === viewer && c.toAddress.toLowerCase() === target) + )) +} + +export async function mockCreateConnectionRequest(ctx: MockApiContext, targetAddress: string): Promise { + await initPromise + if (!ctx.address) throw new Error('Not logged in') + mockConnections.push({ + id: `conn-${Date.now()}`, + fromAddress: ctx.address, + toAddress: targetAddress, + status: 'pending', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }) +} + +export async function mockAcceptConnectionRequest(ctx: MockApiContext, targetAddress: string): Promise { + await initPromise + if (!ctx.address) throw new Error('Not logged in') + const viewer = ctx.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() + } +} + +export async function mockRejectConnectionRequest(ctx: MockApiContext, targetAddress: string): Promise { + await initPromise + if (!ctx.address) throw new Error('Not logged in') + const viewer = ctx.address.toLowerCase() + const target = targetAddress.toLowerCase() + + setMockConnections(mockConnections.filter(c => + !(c.status === 'pending' && + c.fromAddress.toLowerCase() === target && + c.toAddress.toLowerCase() === viewer) + )) +} \ No newline at end of file diff --git a/lib/api/mock/state.ts b/lib/api/mock/state.ts new file mode 100644 index 0000000..88fa4ba --- /dev/null +++ b/lib/api/mock/state.ts @@ -0,0 +1,184 @@ +/** + * lib/api/mock/state.ts + * + * The in-memory mock store: per-community state, lazy initialisation, and + * persistence orchestration (IndexedDB/localStorage fallback). Extracted + * from lib/api/mock.ts so the store lives in one focused module that every + * mock API domain can share. + */ +import type { + AccessPolicy, + Community, + MemberProfile, + Membership, + PendingAction, + Proposal, + Resource, + Role, + Vote, + WebhookEventLog, +} from '../types' +import { + DEFAULT_COMMUNITY, + DEFAULT_MEMBER_STORE, + DEFAULT_POLICIES, + DEFAULT_RESOURCES, + DEFAULT_WEBHOOK_EVENTS, + MOCK_COMMUNITIES, + MOCK_MEMBER_STORES, + MOCK_POLICIES, + MOCK_RESOURCES, +} from './fixtures' +import { + LS_KEY, + loadPersistedState, + persistState, +} from '../mock-storage' + +/** + * Call context shared by every mock API domain implementation. Built by + * MockAccessApi per call so `config`-derived values are always fresh. + */ +export interface MockApiContext { + address?: string + communityId: string + /** Auth mode as read from config (bearer vs. cookie session simulation). */ + authMode: 'bearer' | 'cookie' +} + +export interface CommunityState { + community: Community + resources: Resource[] + policies: AccessPolicy[] + webhookEvents: WebhookEventLog[] + memberStore: Record + pendingActions: PendingAction[] + proposals: Record + votes: Record // Maps vote ID to Vote +} + +export let communityStates: Record = {} + +export function getCommunityState(communityId: string = 'guildpass-demo'): CommunityState { + const normalizedId = MOCK_COMMUNITIES[communityId] ? communityId : 'guildpass-demo' + if (!communityStates[normalizedId]) { + communityStates[normalizedId] = { + community: { ...MOCK_COMMUNITIES[normalizedId] }, + resources: [...(MOCK_RESOURCES[normalizedId] ?? [])], + policies: [...(MOCK_POLICIES[normalizedId] ?? [])], + webhookEvents: [...DEFAULT_WEBHOOK_EVENTS], + memberStore: Object.fromEntries( + Object.entries(MOCK_MEMBER_STORES[normalizedId] ?? {}).map(([k, v]) => [ + k, + { ...v, roles: [...v.roles], membership: { ...v.membership }, profile: { ...v.profile } } + ]) + ), + pendingActions: [], + proposals: {}, + votes: {}, + } + } + return communityStates[normalizedId] +} + +/** Rebuild every community's state from scratch (used by resetMockData). */ +export function resetCommunityStates(): void { + communityStates = {} + for (const cid of Object.keys(MOCK_COMMUNITIES)) { + getCommunityState(cid) + } +} + +/** Generate a random webhook event and prepend it to the live feed. */ +export function createMockStreamEvent(communityId: string = 'guildpass-demo'): WebhookEventLog { + const state = getCommunityState(communityId) + const base = DEFAULT_WEBHOOK_EVENTS[Math.floor(Math.random() * DEFAULT_WEBHOOK_EVENTS.length)] + const statuses: WebhookEventLog['status'][] = ['success', 'pending', 'failed'] + const event: WebhookEventLog = { + ...base, + id: `stream_${Date.now()}_${Math.random().toString(16).slice(2)}`, + timestamp: new Date().toISOString(), + status: statuses[Math.floor(Math.random() * statuses.length)], + isReplay: false, + fullPayload: { + ...(base.fullPayload ?? base.payloadSummary), + source: 'mock-sse-stream', + }, + } + state.webhookEvents.unshift(event) + return event +} + +let saveTimeout: ReturnType | null = null + +async function saveState() { + if (saveTimeout) clearTimeout(saveTimeout) + saveTimeout = setTimeout(async () => { + await persistState({ communityStates } as any) + }, 100) +} + +/** Debounced persistence trigger β€” call after any mutating mock operation. */ +export function schedulePersist(): void { + saveState().catch(() => {}) +} + +/** + * Resolves once the persisted store (if any) has been loaded into + * `communityStates`. Every mock API operation awaits it first. + */ +export const initPromise = loadPersistedState().then((persisted) => { + if (!persisted) { + resetCommunityStates() + return + } + if ((persisted as any).communityStates) { + communityStates = (persisted as any).communityStates + } else { + // Backward compatibility: load legacy state into guildpass-demo + communityStates['guildpass-demo'] = { + community: (persisted as any).community || { ...DEFAULT_COMMUNITY }, + resources: (persisted as any).resources || [...DEFAULT_RESOURCES], + policies: (persisted as any).policies || [...DEFAULT_POLICIES], + webhookEvents: (persisted as any).webhookEvents || [...DEFAULT_WEBHOOK_EVENTS], + memberStore: (persisted as any).memberStore || { ...DEFAULT_MEMBER_STORE }, + pendingActions: (persisted as any).pendingActions || [], + proposals: (persisted as any).proposals || {}, + votes: (persisted as any).votes || {}, + } + } + for (const cid of Object.keys(MOCK_COMMUNITIES)) { + getCommunityState(cid) + } +}) + +if (typeof window !== 'undefined') { + window.addEventListener('beforeunload', () => { + if (saveTimeout) clearTimeout(saveTimeout) + try { + localStorage.setItem(LS_KEY, JSON.stringify({ communityStates })) + } catch { /* ignore */ } + }) +} + +/** Lazily seed a member record for the given address, mirroring a backend that auto-provisions members. */ +export function ensureAddress(addr?: string, communityId: string = 'guildpass-demo') { + if (!addr) return null + const state = getCommunityState(communityId) + if (!state.memberStore[addr]) { + state.memberStore[addr] = { + membership: { + address: addr, + tier: 'free', + active: true, + }, + roles: ['member'], + profile: { + address: addr, + displayName: `User ${addr.slice(0, 6)}`, + badges: ['Early Member', 'Beta Tester'], + }, + } + } + return state.memberStore[addr] +} \ No newline at end of file diff --git a/lib/api/mock/webhooks.ts b/lib/api/mock/webhooks.ts new file mode 100644 index 0000000..82427b7 --- /dev/null +++ b/lib/api/mock/webhooks.ts @@ -0,0 +1,177 @@ +/** + * lib/api/mock/webhooks.ts + * + * Webhook & admin-event domain of the mock API: the live webhook feed, + * event replay (both the API method and the standalone dev-tool export), + * and the admin event log pagination. Extracted from lib/api/mock.ts. + */ +import { ApiError } from '../errors' +import { + createMockStreamEvent, + getCommunityState, + initPromise, + schedulePersist, + type MockApiContext, +} from './state' +import type { + AdminEventFilterParams, + MembershipTier, + Paginated, + WebhookEvent, + WebhookEventLog, + WebhookEventUnsubscribe, +} from '../types' + +export async function mockListWebhookEvents(ctx: MockApiContext, _signal?: AbortSignal): Promise { + await initPromise + const state = getCommunityState(ctx.communityId) + return new Promise((resolve) => setTimeout(() => resolve(state.webhookEvents), 300)) +} + +export function mockSubscribeWebhookEvents( + communityId: string, + onEvent: (event: WebhookEventLog) => void, +): WebhookEventUnsubscribe { + const cid = communityId + const intervalId = globalThis.setInterval(() => { + onEvent(createMockStreamEvent(cid)) + }, 5000) + + globalThis.setTimeout(() => onEvent(createMockStreamEvent(cid)), 1000) + return () => globalThis.clearInterval(intervalId) +} + +export async function mockReplayEvent(ctx: MockApiContext, eventId: string): Promise { + await initPromise + const state = getCommunityState(ctx.communityId) + const original = state.webhookEvents.find((e) => e.id === eventId) + if (!original) { + throw new ApiError({ + status: 404, + code: 'not_found', + safeMessage: `Event "${eventId}" not found in mock store.`, + }) + } + + const replay: WebhookEventLog = { + ...original, + id: `replay_${eventId}_${Date.now()}`, + timestamp: new Date().toISOString(), + isReplay: true, + status: 'pending', + fullPayload: original.fullPayload ?? { ...original.payloadSummary }, + } + + state.webhookEvents.unshift(replay) + schedulePersist() + return replay +} + +/** + * Replay a webhook event by cloning it into the mock event store. + * The clone is marked with `isReplay: true` and inserted at the top + * of the feed with a `pending` status so it is visually distinct. + * + * This function operates directly on the module-level mock store and + * is intended for use by the admin event replay tool. It must only be + * called when `config.apiMode === 'mock'`. + */ +export async function replayMockEvent(eventId: string, communityId: string = 'guildpass-demo'): Promise { + await initPromise + const state = getCommunityState(communityId) + const original = state.webhookEvents.find((e) => e.id === eventId) + if (!original) { + throw new ApiError({ + status: 404, + code: 'not_found', + safeMessage: `Event "${eventId}" not found in mock store.`, + }) + } + + const replay: WebhookEventLog = { + ...original, + id: `replay_${eventId}_${Date.now()}`, + timestamp: new Date().toISOString(), + isReplay: true, + status: 'pending', + fullPayload: original.fullPayload ?? { ...original.payloadSummary }, + } + + state.webhookEvents.unshift(replay) + schedulePersist() + + // Apply side effects to the member store for recognised event types. + const addr = original.affectedIdentifier + if (addr && addr.startsWith('0x')) { + const existing = state.memberStore[addr] + switch (original.eventType) { + case 'membership.created': + case 'membership.renewed': { + const tier = (original.payloadSummary.tier as MembershipTier) ?? 'free' + state.memberStore[addr] = { + membership: { address: addr, tier, active: true }, + roles: existing?.roles ?? ['member'], + profile: existing?.profile ?? { address: addr, displayName: `Replayed ${addr.slice(0, 6)}`, badges: [] }, + } + break + } + case 'membership.expired': + if (existing) { + state.memberStore[addr] = { + ...existing, + membership: { ...existing.membership, active: false }, + } + } + break + case 'tier.upgraded': { + const newTier = (original.payloadSummary.tier as MembershipTier) ?? 'standard' + if (existing) { + state.memberStore[addr] = { + ...existing, + membership: { ...existing.membership, tier: newTier }, + } + } + break + } + // policy.updated β€” no member-store side effect + } + } + + return replay +} + +export async function mockListAdminEvents( + ctx: MockApiContext, + params?: AdminEventFilterParams, +): Promise> { + let events = getCommunityState(ctx.communityId).webhookEvents as any[] + + if (params?.types && params.types.length > 0) { + events = events.filter((e) => params.types!.includes(e.type)) + } + + if (params?.startDate) { + const start = new Date(params.startDate) + events = events.filter((e) => new Date(e.createdAt) >= start) + } + + if (params?.endDate) { + // Include the end date fully (e.g., up to end of the day) + const end = new Date(params.endDate) + end.setUTCHours(23, 59, 59, 999) + events = events.filter((e) => new Date(e.createdAt) <= end) + } + + const page = params?.page || 1 + const limit = params?.limit || 20 + const startIndex = (page - 1) * limit + + const paginated = events.slice(startIndex, startIndex + limit) + + return { + data: paginated, + total: events.length, + page, + limit + } +} \ No newline at end of file