diff --git a/package-lock.json b/package-lock.json index d8f0116..68fb2fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -330,7 +330,6 @@ "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-5.7.8.tgz", "integrity": "sha512-rrZcm/2vJM0WdWRQup1TUidbjQV9PfIadSkV4rAGLD7R6PuzZSMPGT0gmoZzCRlXkqrazrWWDkurei3ozU02FA==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -5557,7 +5556,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -8181,7 +8179,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -9063,7 +9060,6 @@ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "license": "MIT", - "peer": true, "dependencies": { "whatwg-url": "^5.0.0" }, @@ -9591,7 +9587,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -10942,7 +10937,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -11007,7 +11001,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11076,7 +11069,6 @@ "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", "license": "MIT", - "peer": true, "dependencies": { "@fastify/busboy": "^2.0.0" }, @@ -11208,7 +11200,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -11275,7 +11266,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz", "integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.30", "@vue/compiler-sfc": "3.5.30", @@ -11323,7 +11313,6 @@ "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", "license": "MIT", - "peer": true, "dependencies": { "@vue/devtools-api": "^6.6.4" }, diff --git a/relay-server.js b/relay-server.js index 0c87493..cdabd0a 100644 --- a/relay-server.js +++ b/relay-server.js @@ -476,9 +476,8 @@ server.on('request', (req, res) => { const key = `${pollId}:${deviceId}`; const alreadyVoted = voteRegistry.has(key); - if (!alreadyVoted) { - voteRegistry.add(key); - } + // CHECK-ONLY: do NOT add to voteRegistry here. + // The client must call /api/vote-confirm after the vote succeeds. // Log the authorization attempt const logEntry = { @@ -502,6 +501,42 @@ server.on('request', (req, res) => { return; } + // Two-phase vote: confirm endpoint registers the vote after it succeeds on-chain + if (req.method === 'POST' && url.pathname === '/api/vote-confirm') { + parseBodyWithLimit(req, res, 4096).then((data) => { + if (!data) return; + try { + const pollId = sanitizeId(String(data.pollId || ''), 128); + const deviceId = sanitizeId(String(data.deviceId || ''), 128); + + if (!pollId || !deviceId) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, reason: 'missing or invalid pollId or deviceId' })); + return; + } + + const key = `${pollId}:${deviceId}`; + voteRegistry.add(key); + + const logEntry = { + type: 'vote-confirm', + pollId, + deviceId, + timestamp: Date.now(), + }; + fs.appendFile(RECEIPT_LOG_FILE, JSON.stringify(logEntry) + '\n', () => {}); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + } catch (error) { + console.error('Error in /api/vote-confirm:', error.message); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, reason: 'internal error' })); + } + }); + return; + } + if (req.method === 'POST' && url.pathname === '/api/receipts') { parseBodyWithLimit(req, res, 16384).then((data) => { if (!data) return; diff --git a/src/services/auditService.ts b/src/services/auditService.ts index 1c0b705..4e2db75 100644 --- a/src/services/auditService.ts +++ b/src/services/auditService.ts @@ -1,5 +1,4 @@ import config from '../config'; -import { IntegrityService } from '@/services/integrityService'; export type ReceiptKind = 'vote' | 'comment'; @@ -8,19 +7,17 @@ interface VoteAuthorizeResponse { reason?: string; } +const AUTHORIZE_TIMEOUT_MS = 8_000; + export class AuditService { static async logReceipt(type: ReceiptKind, payload: any): Promise { try { - const body = await IntegrityService.seal( - { type, payload } as Record, - 'broadcast', - ); await fetch(`${config.relay.api}/api/receipts`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify(body), + body: JSON.stringify({ type, payload }), }); } catch (_error) { // Backend is optional; fail silently @@ -29,23 +26,27 @@ export class AuditService { /** * Ask backend if this device is allowed to vote on a poll. - * If the backend is unreachable or returns an unexpected response, - * we default to allowing the vote so offline mode still works. + * CHECK-ONLY: does NOT register the vote. Call confirmVote() after + * the vote succeeds on-chain. + * If the backend is unreachable, times out, or returns an unexpected + * response, we default to allowing the vote so offline mode still works. */ static async authorizeVote(pollId: string, deviceId: string): Promise { try { - const body = await IntegrityService.seal( - { pollId, deviceId } as Record, - 'vote-authorize', - ); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), AUTHORIZE_TIMEOUT_MS); + const res = await fetch(`${config.relay.api}/api/vote-authorize`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify(body), + body: JSON.stringify({ pollId, deviceId }), + signal: controller.signal, }); + clearTimeout(timer); + if (!res.ok) { return true; } @@ -60,4 +61,29 @@ export class AuditService { return true; } } + + /** + * Register a successful vote with the backend so it blocks future + * duplicate attempts from the same device. + * Non-blocking — failure here doesn't affect the vote. + */ + static async confirmVote(pollId: string, deviceId: string): Promise { + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), AUTHORIZE_TIMEOUT_MS); + + await fetch(`${config.relay.api}/api/vote-confirm`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ pollId, deviceId }), + signal: controller.signal, + }); + + clearTimeout(timer); + } catch (_error) { + // Backend is optional; fail silently + } + } } diff --git a/src/services/copilot-services.md b/src/services/copilot-services.md index 6b7e136..d9debf7 100644 --- a/src/services/copilot-services.md +++ b/src/services/copilot-services.md @@ -32,7 +32,7 @@ All services are **static classes** — never instantiated with `new`. Initializ | File | Class | Purpose | |---|---|---| -| `pollService.ts` | `PollService` | Poll CRUD, invite code generation/validation, vote recording in GunDB. Schnorr-signs polls on create (`authorPubkey`, `contentSignature`). Community/all-poll subscriptions use hydration-first loading with batch processing plus soft/hard timeouts to avoid startup hangs while preventing startup content from being treated as "new". Cold-start `loadPoll()` / `loadPollOptions()` now try Gun cache first, then wait for live Gun relay sync, then fall back to the gun-relay MySQL search endpoint (`/db/search?prefix=v2/polls/{id}`) and warm Gun cache from that result instead of relying on `/db/soul`. Encrypts poll content (question, options, description) via `EncryptionService`/`KeyVaultService` when community has an encryption key; `decryptPoll()` reverses at read time. Also calls `indexForSearch()` to push data to relay for full-text search. | +| `pollService.ts` | `PollService` | Poll CRUD, invite code generation/validation, vote recording in GunDB. Schnorr-signs polls on create (`authorPubkey`, `contentSignature`). Community/all-poll subscriptions use hydration-first loading with batch processing plus soft/hard timeouts to avoid startup hangs while preventing startup content from being treated as "new". Cold-start `loadPoll()` / `loadPollOptions()` now try Gun cache first, then wait for live Gun relay sync, then fall back to the gun-relay MySQL search endpoint (`/db/search?prefix=v2/polls/{id}`) and warm Gun cache from that result instead of relying on `/db/soul`. Poll option voter lists are normalized from Gun-safe object storage back into arrays for the app, and voting rewrites the full options map (global + community copy) so option order and vote counts stay aligned. Encrypts poll content (question, options, description) via `EncryptionService`/`KeyVaultService` when community has an encryption key; `decryptPoll()` reverses at read time. Also calls `indexForSearch()` to push data to relay for full-text search. | | `communityService.ts` | `CommunityService` | Community CRUD in GunDB. IDs are derived from lowercased name: `c-{slug}`. Signs community creation with Schnorr (via `CryptoService`/`KeyService`) for anti-sabotage; includes `verifyCommunitySignature()` to check integrity. Supports private encrypted communities via `createPrivateCommunity()` (AES-256-GCM encrypted metadata, password-derived or invite-only keys), `decryptCommunityMeta()` (decrypt using stored key), and `joinPrivateCommunity()` (join with invite key or password). `subscribeToCommunitiesLive()` now forwards repeat updates so member-count and metadata changes are not dropped after first load. Uses `EncryptionService`, `KeyVaultService`, and `InviteLinkService`. | | `postService.ts` | `PostService` | Post CRUD in GunDB, image upload via `IPFSService`. Subscription loaders (`subscribeToPostsInCommunity`, `subscribeToAllPosts`) track initial hydration with idle/hard timeouts and per-item watchdogs so startup data loads reliably without false "new content" classification. Signs post content with Schnorr (via `CryptoService`/`KeyService`) for anti-sabotage verification; exposes `verifyPostSignature()` returning `'verified' | 'unverified' | 'unsigned'`. Encrypts post content (title, body, author info, images, signature) via `EncryptionService`/`KeyVaultService` when community has an encryption key; `decryptPost()` reverses at read time with HMAC authTag verification and type-validated decryption. | | `commentService.ts` | `CommentService` | Comment CRUD in GunDB. Schnorr-signs comment content on create/edit for anti-sabotage verification (`authorPubkey`, `contentSignature`). Encrypts comment content via `EncryptionService`/`KeyVaultService` when community has an encryption key; `decryptComment()` reverses at read time. `verifyCommentSignature()` returns `'verified' | 'unverified' | 'unsigned'`. | diff --git a/src/services/dbWarmup.ts b/src/services/dbWarmup.ts index 8e8294b..de86292 100644 --- a/src/services/dbWarmup.ts +++ b/src/services/dbWarmup.ts @@ -12,14 +12,13 @@ import { isVersionEnabled } from '../utils/dataVersionSettings' import { GUN_NAMESPACE } from './gunService' - -const NUXT_API = 'https://interpoll.endless.sbs' +import config from '../config' let warmupDone = false // ── Shared fetch with stale-while-revalidate ────────────────────────────────── async function apiFetch(path: string): Promise { - const res = await fetch(`${NUXT_API}${path}`, { + const res = await fetch(`${config.relay.api}${path}`, { headers: { 'Cache-Control': 'stale-while-revalidate=30' }, }) if (!res.ok) throw new Error(`API ${path} → ${res.status}`) @@ -151,7 +150,6 @@ export async function warmupFromDB(): Promise { // ── v1 legacy posts — Gun relay search, non-blocking ───────────────────────── async function fetchV1Posts(postStore: any) { try { - const { default: config } = await import('../config') const base = config.relay.gun.replace(/\/gun$/, '') const res = await fetch(`${base}/db/search?prefix=posts&limit=500`) if (!res.ok) return diff --git a/src/services/pollService.ts b/src/services/pollService.ts index 90f979e..2aacf70 100644 --- a/src/services/pollService.ts +++ b/src/services/pollService.ts @@ -3,8 +3,6 @@ import { EncryptionService } from './encryptionService'; import { KeyVaultService } from './keyVaultService'; import config from '../config'; -const API_URL = 'https://interpoll.endless.sbs'; - function getGunRelayBase(): string { return config.relay.gun.replace(/\/gun$/, ''); } @@ -49,7 +47,7 @@ async function indexForSearch(type: 'post' | 'poll', id: string, data: any) { { type, id, data } as Record, 'index', ); - await fetch(`${API_URL}/api/index`, { + await fetch(`${config.relay.api}/api/index`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', @@ -115,22 +113,53 @@ export class PollService { if (key === '_') return; const opt = optionsData[key]; if (opt && opt.id) { - options.push({ id: opt.id, text: opt.text || '', votes: opt.votes || 0, voters: opt.voters || [] }); + options.push({ + id: opt.id, + text: opt.text || '', + votes: opt.votes || 0, + voters: this.parseVoters(opt.voters), + }); } }); return options; } + private static parseVoters(votersData: any): string[] { + if (!votersData) return []; + if (Array.isArray(votersData)) { + return votersData.filter((voterId): voterId is string => typeof voterId === 'string'); + } + if (typeof votersData !== 'object') return []; + + return Object.entries(votersData) + .filter(([key]) => key !== '_') + .map(([key, value]) => { + if (typeof value === 'string') return value; + if (value === true || value === 1) return key; + return null; + }) + .filter((voterId): voterId is string => typeof voterId === 'string'); + } + + private static buildVotersMap(voters: string[]): Record { + return Object.fromEntries( + voters.map((voterId, index) => [index, voterId]) + ); + } + private static buildOptionsMap(options: PollOption[]): Record { return Object.fromEntries(options.map((option, index) => [index, { - id: option.id, text: option.text, votes: option.votes || 0, voters: option.voters || [], + id: option.id, + text: option.text, + votes: option.votes || 0, + voters: this.buildVotersMap(option.voters || []), }])); } // ── API-first poll fetch (replaces Gun-first approach) ─────────────────────── private static async loadPollFromAPI(pollId: string): Promise<{ pollData: any | null; options: PollOption[] }> { try { - const res = await fetch(`${API_URL}/api/poll/${pollId}`, { + const res = await fetch(`${config.relay.api}/api/poll/${pollId}`, { headers: { 'Cache-Control': 'stale-while-revalidate=30' }, }); if (!res.ok) return { pollData: null, options: [] }; @@ -434,8 +463,7 @@ export class PollService { totalVotes: 0, isExpired: false, }; - const optionsMap: Record = {}; - pollOptions.forEach((opt, i) => { optionsMap[i] = { id: opt.id, text: opt.text, votes: 0 }; }); + const optionsMap = this.buildOptionsMap(pollOptions); try { const { KeyService } = await import('./keyService'); @@ -586,17 +614,31 @@ export class PollService { const selectedOptions = poll.options.filter(opt => optionIds.includes(opt.id)); if (selectedOptions.length === 0) throw new Error('No valid options selected'); if (!poll.allowMultipleChoices && selectedOptions.length > 1) throw new Error('Multiple choices not allowed'); - for (const option of selectedOptions) { - if (!option.voters.includes(voterId)) { - await this.putPromise( - this.getPollPath(pollId).get('options').get(option.id), - { votes: (option.votes || 0) + 1, voters: [...option.voters, voterId] } - ); + const updatedOptions = poll.options.map((option) => { + if (!optionIds.includes(option.id) || option.voters.includes(voterId)) { + return option; } - } - const updatedOptions = await this.loadPollOptions(pollId); - const totalVotes = updatedOptions.reduce((sum, opt) => sum + (opt.votes || 0), 0); - await this.putPromise(this.getPollPath(pollId), { totalVotes }); + + return { + ...option, + votes: (option.votes || 0) + 1, + voters: [...option.voters, voterId], + }; + }); + const totalVotes = updatedOptions.reduce((sum, opt) => sum + (opt.votes || 0), 0); + const optionsMap = this.buildOptionsMap(updatedOptions); + const pollPatch = { totalVotes }; + + await Promise.all([ + this.putPromise(this.getPollPath(pollId).get('options'), optionsMap), + this.putPromise(this.getPollPath(pollId), pollPatch), + poll.communityId + ? this.putPromise(this.getCommunityPollPath(poll.communityId, pollId).get('options'), optionsMap) + : Promise.resolve(), + poll.communityId + ? this.putPromise(this.getCommunityPollPath(poll.communityId, pollId), pollPatch) + : Promise.resolve(), + ]); } static async voteOnPoll(pollId: string, optionIds: string[], voterId: string): Promise { @@ -629,9 +671,18 @@ export class PollService { return codes; } - private static putPromise(node: any, data: any): Promise { + private static putPromise(node: any, data: any, timeoutMs = 8000): Promise { return new Promise((resolve, reject) => { + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + reject(new Error('Gun put timed out')); + }, timeoutMs); node.put(data, (ack: any) => { + if (settled) return; + settled = true; + clearTimeout(timer); if (ack.err) reject(new Error(ack.err)); else resolve(); }); }); @@ -666,4 +717,4 @@ export class PollService { }; } catch { return poll; } } -} \ No newline at end of file +} diff --git a/src/stores/chainStore.ts b/src/stores/chainStore.ts index e1fee5f..93151b6 100644 --- a/src/stores/chainStore.ts +++ b/src/stores/chainStore.ts @@ -86,6 +86,7 @@ export const useChainStore = defineStore('chain', () => { if (blocks.value.length > 0 && block.index === blocks.value.length) { const previousBlock = blocks.value[blocks.value.length - 1]; + if (previousBlock.currentHash !== block.previousHash) return; if (ChainService.validateBlock(block, previousBlock)) { await StorageService.saveBlock(block); blocks.value.push(block); diff --git a/src/stores/copilot-stores.md b/src/stores/copilot-stores.md index abe7551..44f7007 100644 --- a/src/stores/copilot-stores.md +++ b/src/stores/copilot-stores.md @@ -9,7 +9,7 @@ All stores use the **Composition API form** of Pinia: `defineStore('name', () => The most critical store. Owns the local blockchain. - **Init**: Call `chainStore.initialize()` once on app start. It calls `BroadcastService.initialize()`, `WebSocketService.initialize()`, `ChainService.initializeChain()`, then wires sync listeners for both channels. -- **Sync protocol**: On connect, sends `request-sync` with `lastIndex` (incremental — only fetches missing blocks). Responds to `sync-response` by validating and appending blocks. Conflict resolution: same index + different hash → ignore remote block (local chain wins). +- **Sync protocol**: On connect, sends `request-sync` with `lastIndex` (incremental — only fetches missing blocks). Responds to `sync-response` by validating and appending blocks. Conflict resolution: same index + different hash → ignore remote block (local chain wins). Live `new-block` deliveries also short-circuit when the parent hash does not match the current local head, avoiding noisy validation failures for competing branches. - **Voting**: `addVote(vote)` → creates block → broadcasts on both channels → saves receipt → calls `AuditService.logReceipt`. - **Actions**: `addAction(actionType, data, label)` records non-vote events (community creation, post creation) as blocks. - **Nostr events**: Every vote also creates and broadcasts a signed Nostr event via `EventService`. diff --git a/src/views/HomePage.vue b/src/views/HomePage.vue index c2460ac..1ef4b29 100644 --- a/src/views/HomePage.vue +++ b/src/views/HomePage.vue @@ -847,8 +847,10 @@ async function handleDownvote(post: Post) { function getCommunityName(communityId: string): string { return communityStore.communities.find(c => c.id === communityId)?.displayName || communityId; } -function navigateToPost(post: Post) { router.push(`/community/${post.communityId}/post/${post.id}`); } -function navigateToPoll(poll: Poll) { router.push(`/community/${poll.communityId}/poll/${poll.id}`); } +// Full-page reload on post/poll navigation ensures fresh GunDB subscriptions +// and avoids stale-store interference when navigating between content items +function navigateToPost(post: Post) { window.location.href = `/community/${post.communityId}/post/${post.id}`; } +function navigateToPoll(poll: Poll) { window.location.href = `/community/${poll.communityId}/poll/${poll.id}`; } // ── Community subscriptions ─────────────────────────────────────────────────── diff --git a/src/views/PollDetailPage.vue b/src/views/PollDetailPage.vue index 7056672..48b45dc 100644 --- a/src/views/PollDetailPage.vue +++ b/src/views/PollDetailPage.vue @@ -26,7 +26,7 @@

Poll not found

- Go Home + Go Home
@@ -133,7 +133,7 @@ Enter Invite Code @@ -242,7 +242,7 @@