Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 0 additions & 11 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

41 changes: 38 additions & 3 deletions relay-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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;
Expand Down
52 changes: 39 additions & 13 deletions src/services/auditService.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import config from '../config';
import { IntegrityService } from '@/services/integrityService';

export type ReceiptKind = 'vote' | 'comment';

Expand All @@ -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<void> {
try {
const body = await IntegrityService.seal(
{ type, payload } as Record<string, unknown>,
'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
Expand All @@ -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<boolean> {
try {
const body = await IntegrityService.seal(
{ pollId, deviceId } as Record<string, unknown>,
'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;
}
Expand All @@ -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<void> {
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
}
}
}
2 changes: 1 addition & 1 deletion src/services/copilot-services.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'`. |
Expand Down
6 changes: 2 additions & 4 deletions src/services/dbWarmup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any> {
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}`)
Expand Down Expand Up @@ -151,7 +150,6 @@ export async function warmupFromDB(): Promise<void> {
// ── 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
Expand Down
Loading