From e70244d7c1461e238ae7a6679dae17d29f6db110 Mon Sep 17 00:00:00 2001 From: Lucio Rubens Date: Thu, 19 Feb 2026 09:05:48 -0300 Subject: [PATCH 1/6] feat(social): add social proof linking and verification flow --- bun.lock | 1 + packages/api-main/drizzle/schema.ts | 23 +++ packages/api-main/src/gets/index.ts | 2 + packages/api-main/src/gets/socialLinks.ts | 75 +++++++++ packages/api-main/src/gets/socialResolve.ts | 53 ++++++ packages/api-main/src/posts/index.ts | 1 + packages/api-main/src/posts/social.ts | 65 ++++++++ packages/api-main/src/routes/public.ts | 4 +- packages/api-main/src/routes/reader.ts | 3 +- packages/api-main/src/social/code.ts | 1 + packages/api-main/src/social/providers/x.ts | 66 ++++++++ packages/api-main/src/social/verify.ts | 107 ++++++++++++ packages/api-main/src/types/social-link.ts | 10 ++ packages/frontend-main/package.json | 1 + .../src/composables/useSocialLinks.ts | 43 +++++ packages/frontend-main/src/types/index.ts | 2 + packages/frontend-main/src/utility/social.ts | 13 ++ .../src/views/Profile/ProfileViewWrapper.vue | 154 +++++++++++++++++- packages/lib-api-types/src/gets/index.ts | 10 ++ packages/lib-api-types/src/index.ts | 1 + packages/lib-api-types/src/posts/index.ts | 10 ++ packages/lib-api-types/src/utils.ts | 15 ++ packages/reader-main/src/messages/index.ts | 2 + packages/reader-main/src/messages/social.ts | 70 ++++++++ 24 files changed, 728 insertions(+), 4 deletions(-) create mode 100644 packages/api-main/src/gets/socialLinks.ts create mode 100644 packages/api-main/src/gets/socialResolve.ts create mode 100644 packages/api-main/src/posts/social.ts create mode 100644 packages/api-main/src/social/code.ts create mode 100644 packages/api-main/src/social/providers/x.ts create mode 100644 packages/api-main/src/social/verify.ts create mode 100644 packages/api-main/src/types/social-link.ts create mode 100644 packages/frontend-main/src/composables/useSocialLinks.ts create mode 100644 packages/frontend-main/src/utility/social.ts create mode 100644 packages/lib-api-types/src/utils.ts create mode 100644 packages/reader-main/src/messages/social.ts diff --git a/bun.lock b/bun.lock index b585e8e4..67cfc6b9 100644 --- a/bun.lock +++ b/bun.lock @@ -72,6 +72,7 @@ "name": "@dither.chat/frontend", "version": "0.0.1", "dependencies": { + "@atomone/dither-api-types": "workspace:*", "@cosmjs/amino": "^0.33.1", "@cosmjs/math": "^0.37.0", "@cosmjs/proto-signing": "^0.37.0", diff --git a/packages/api-main/drizzle/schema.ts b/packages/api-main/drizzle/schema.ts index 80cde5ba..ddcb08e7 100644 --- a/packages/api-main/drizzle/schema.ts +++ b/packages/api-main/drizzle/schema.ts @@ -131,6 +131,8 @@ export const ModeratorTable = pgTable('moderators', { export const notificationTypeEnum = pgEnum('notification_type', ['like', 'dislike', 'flag', 'follow', 'reply']); +export const socialLinkStatusEnum = pgEnum('social_link_status', ['pending', 'verified', 'failed']); + export const NotificationTable = pgTable( 'notifications', { @@ -154,6 +156,26 @@ export const ReaderState = pgTable('state', { last_block: varchar().notNull(), }); +export const SocialLinksTable = pgTable( + 'social_links', + { + id: serial().primaryKey(), + hash: varchar({ length: 64 }).notNull(), + address: varchar({ length: 44 }).notNull(), + handle: varchar({ length: 128 }).notNull(), + platform: varchar({ length: 32 }).notNull(), + status: socialLinkStatusEnum().notNull().default('pending'), + error_reason: text(), + proof_url: varchar({ length: 512 }).notNull(), + created_at: timestamp({ withTimezone: true }).notNull().defaultNow(), + }, + t => [ + index('social_links_address_idx').on(t.address), + index('social_links_handle_idx').on(t.handle), + index('social_links_status_idx').on(t.status), + ], +); + export const tables = [ 'feed', 'likes', @@ -166,4 +188,5 @@ export const tables = [ 'state', 'authrequests', 'ratelimits', + 'social_links', ]; diff --git a/packages/api-main/src/gets/index.ts b/packages/api-main/src/gets/index.ts index 5425689a..aeead5ff 100644 --- a/packages/api-main/src/gets/index.ts +++ b/packages/api-main/src/gets/index.ts @@ -14,3 +14,5 @@ export * from './post'; export * from './posts'; export * from './replies'; export * from './search'; +export * from './socialLinks'; +export * from './socialResolve'; diff --git a/packages/api-main/src/gets/socialLinks.ts b/packages/api-main/src/gets/socialLinks.ts new file mode 100644 index 00000000..dd46250a --- /dev/null +++ b/packages/api-main/src/gets/socialLinks.ts @@ -0,0 +1,75 @@ +import type { Gets } from '@atomone/dither-api-types'; + +import { and, eq } from 'drizzle-orm'; + +import { getDatabase } from '../../drizzle/db'; +import { SocialLinksTable } from '../../drizzle/schema'; +import { verifyJWT } from '../shared/jwt'; +import { SOCIAL_LINK_STATUS } from '../types/social-link'; + +/** + * GET /social/links?address=... + * + * Public callers (no auth): returns only verified links. + * Owner (valid JWT cookie matching the requested address): returns all links + */ +export async function SocialLinks(query: Gets.SocialLinksQuery, authToken: string | undefined) { + if (!query.address || query.address.length !== 44) { + return { status: 400, error: 'Valid address is required' }; + } + + const address = query.address.toLowerCase(); + + // Determine if the caller is the owner of this address + let isOwner = false; + if (authToken) { + const tokenAddress = await verifyJWT(authToken); + if (tokenAddress && tokenAddress.toLowerCase() === address) { + isOwner = true; + } + } + + try { + if (isOwner) { + // Owner: all statuses, include error_reason + const rows = await getDatabase() + .select({ + id: SocialLinksTable.id, + handle: SocialLinksTable.handle, + platform: SocialLinksTable.platform, + status: SocialLinksTable.status, + error_reason: SocialLinksTable.error_reason, + proof_url: SocialLinksTable.proof_url, + created_at: SocialLinksTable.created_at, + }) + .from(SocialLinksTable) + .where(eq(SocialLinksTable.address, address)) + .orderBy(SocialLinksTable.created_at); + + return { status: 200, rows }; + } else { + // Public: verified only, no error_reason + const rows = await getDatabase() + .select({ + id: SocialLinksTable.id, + handle: SocialLinksTable.handle, + platform: SocialLinksTable.platform, + status: SocialLinksTable.status, + created_at: SocialLinksTable.created_at, + }) + .from(SocialLinksTable) + .where( + and( + eq(SocialLinksTable.address, address), + eq(SocialLinksTable.status, SOCIAL_LINK_STATUS.VERIFIED), + ), + ) + .orderBy(SocialLinksTable.created_at); + + return { status: 200, rows }; + } + } catch (err) { + console.error(err); + return { status: 400, error: 'failed to read data from database' }; + } +} diff --git a/packages/api-main/src/gets/socialResolve.ts b/packages/api-main/src/gets/socialResolve.ts new file mode 100644 index 00000000..e901b461 --- /dev/null +++ b/packages/api-main/src/gets/socialResolve.ts @@ -0,0 +1,53 @@ +import type { Gets } from '@atomone/dither-api-types'; + +import { and, desc, eq, sql } from 'drizzle-orm'; + +import { getDatabase } from '../../drizzle/db'; +import { SocialLinksTable } from '../../drizzle/schema'; +import { SOCIAL_LINK_STATUS } from '../types/social-link'; + +const statementGetSocialResolve = getDatabase() + .select({ + address: SocialLinksTable.address, + handle: SocialLinksTable.handle, + platform: SocialLinksTable.platform, + created_at: SocialLinksTable.created_at, + }) + .from(SocialLinksTable) + .where( + and( + eq(SocialLinksTable.handle, sql.placeholder('handle')), + eq(SocialLinksTable.status, SOCIAL_LINK_STATUS.VERIFIED), + ), + ) + .orderBy(desc(SocialLinksTable.created_at)) + .limit(1) + .prepare('stmnt_get_social_resolve'); + +/** + * GET /social/resolve?handle=alice@x + * + * Returns the wallet address associated with a verified social handle. + * Returns 404 if no verified link is found for the handle. + * If multiple verified entries exist (re-verification history), returns the most recent. + */ +export async function SocialResolve(query: Gets.SocialResolveQuery) { + if (!query.handle || !query.handle.includes('@')) { + return { status: 400, error: 'Valid handle in format "username@platform" is required' }; + } + + const handle = query.handle.toLowerCase(); + + try { + const [row] = await statementGetSocialResolve.execute({ handle }); + + if (!row) { + return { status: 404, error: 'No verified link found for this handle' }; + } + + return { status: 200, address: row.address, handle: row.handle, platform: row.platform }; + } catch (err) { + console.error(err); + return { status: 400, error: 'failed to read data from database' }; + } +} diff --git a/packages/api-main/src/posts/index.ts b/packages/api-main/src/posts/index.ts index d7030b77..4d5ed678 100644 --- a/packages/api-main/src/posts/index.ts +++ b/packages/api-main/src/posts/index.ts @@ -9,5 +9,6 @@ export * from './mod'; export * from './post'; export * from './postRemove'; export * from './reply'; +export * from './social'; export * from './unfollow'; export * from './updateState'; diff --git a/packages/api-main/src/posts/social.ts b/packages/api-main/src/posts/social.ts new file mode 100644 index 00000000..ca24c0c5 --- /dev/null +++ b/packages/api-main/src/posts/social.ts @@ -0,0 +1,65 @@ +import type { Posts } from '@atomone/dither-api-types'; + +import { getDatabase } from '../../drizzle/db'; +import { SocialLinksTable } from '../../drizzle/schema'; +import { verifyLink } from '../social/verify'; +import { SOCIAL_LINK_STATUS } from '../types/social-link'; + +export async function SocialProof(body: Posts.SocialProofBody) { + if (body.hash.length !== 64) { + return { status: 400, error: 'Provided hash is not valid' }; + } + + if (body.from.length !== 44) { + return { status: 400, error: 'Provided from address is not valid' }; + } + + if (!body.username || body.username.trim().length === 0) { + return { status: 400, error: 'username is required' }; + } + + if (!body.platform || body.platform.trim().length === 0) { + return { status: 400, error: 'platform is required' }; + } + + if (!body.proof_url || body.proof_url.trim().length === 0) { + return { status: 400, error: 'proof_url is required' }; + } + + // handle stored as "username@platform" + const handle = `${body.username.toLowerCase()}@${body.platform.toLowerCase()}`; + + let insertedId: number; + + try { + const [inserted] = await getDatabase() + .insert(SocialLinksTable) + .values({ + hash: body.hash.toLowerCase(), + address: body.from.toLowerCase(), + handle, + platform: body.platform.toLowerCase(), + status: SOCIAL_LINK_STATUS.PENDING, + proof_url: body.proof_url, + created_at: new Date(body.timestamp), + }) + .returning({ id: SocialLinksTable.id }); + + if (!inserted) { + return { status: 500, error: 'failed to insert social link record' }; + } + + insertedId = inserted.id; + } catch (err) { + console.error(err); + return { status: 500, error: 'failed to communicate with database' }; + } + + // Verification runs in background, we don't want to block the API response. + // Future improvement: if we had a job queue system, we could push a job here instead of this. + verifyLink(insertedId, body.platform.toLowerCase(), body.proof_url, body.from.toLowerCase(), body.username.toLowerCase()).catch( + err => console.error(`verifyLink fire-and-forget error for id=${insertedId}:`, err), + ); + + return { status: 200 }; +} diff --git a/packages/api-main/src/routes/public.ts b/packages/api-main/src/routes/public.ts index 40317c7e..72c2052e 100644 --- a/packages/api-main/src/routes/public.ts +++ b/packages/api-main/src/routes/public.ts @@ -21,4 +21,6 @@ export const publicRoutes = new Elysia() .get('/search', ({ query }) => GetRequests.Search(query), { query: Gets.SearchQuerySchema }) .get('/user-replies', ({ query }) => GetRequests.UserReplies(query), { query: Gets.UserRepliesQuerySchema }) .get('/following-posts', ({ query }) => GetRequests.FollowingPosts(query), { query: Gets.PostsQuerySchema }) - .get('/last-block', GetRequests.LastBlock); + .get('/last-block', GetRequests.LastBlock) + .get('/social/links', ({ query, cookie: { auth } }) => GetRequests.SocialLinks(query, auth.value as string | undefined), { query: Gets.SocialLinksQuerySchema }) + .get('/social/resolve', ({ query }) => GetRequests.SocialResolve(query), { query: Gets.SocialResolveQuerySchema }); diff --git a/packages/api-main/src/routes/reader.ts b/packages/api-main/src/routes/reader.ts index f8e1d973..bce1b264 100644 --- a/packages/api-main/src/routes/reader.ts +++ b/packages/api-main/src/routes/reader.ts @@ -20,4 +20,5 @@ export const readerRoutes = new Elysia() .post('/post-remove', ({ body }) => PostRequests.PostRemove(body), { body: Posts.PostRemoveBodySchema }) .post('/update-state', ({ body }) => PostRequests.UpdateState(body), { body: t.Object({ last_block: t.String() }), - }); + }) + .post('/social/proof', ({ body }) => PostRequests.SocialProof(body), { body: Posts.SocialProofBodySchema }); diff --git a/packages/api-main/src/social/code.ts b/packages/api-main/src/social/code.ts new file mode 100644 index 00000000..f1474c35 --- /dev/null +++ b/packages/api-main/src/social/code.ts @@ -0,0 +1 @@ +export { getSocialProofCode } from '@atomone/dither-api-types'; diff --git a/packages/api-main/src/social/providers/x.ts b/packages/api-main/src/social/providers/x.ts new file mode 100644 index 00000000..9f0768b0 --- /dev/null +++ b/packages/api-main/src/social/providers/x.ts @@ -0,0 +1,66 @@ +import process from 'node:process'; + +import { getSocialProofCode } from '../code'; + +interface XTweetResponse { + data?: { + id: string; + text: string; + author_id: string; + }; + includes?: { + users: Array<{ + id: string; + name: string; + username: string; + }>; + }; + errors?: Array<{ message: string }>; +} + +export async function verifyXTweet(proofUrl: string, address: string, claimedUsername: string): Promise { + const tweetId = extractTweetId(proofUrl); + if (!tweetId) { + throw new Error(`Cannot extract tweet ID from proof URL: ${proofUrl}`); + } + + const bearerToken = process.env.X_BEARER_TOKEN; + if (!bearerToken) { + throw new Error('X_BEARER_TOKEN environment variable is not set'); + } + + const url = `https://api.x.com/2/tweets/${tweetId}?expansions=author_id&user.fields=username`; + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${bearerToken}`, + }, + }); + + if (!response.ok) { + throw new Error(`X API returned ${response.status}: ${response.statusText}`); + } + + const data = await response.json() as XTweetResponse; + + if (!data.data || !data.includes?.users?.[0]) { + throw new Error(`X API response missing expected fields for tweet ${tweetId}`); + } + + const tweetAuthorUsername = data.includes.users[0].username; + const tweetText = data.data.text; + const expectedCode = getSocialProofCode(address); + + const authorMatches = tweetAuthorUsername.toLowerCase() === claimedUsername.toLowerCase(); + const codePresent = tweetText.includes(expectedCode); + + return authorMatches && codePresent; +} + +/** + * Extracts tweet ID from a proof URL. + * Expected format: https://x.com/username/status/1234567890 + */ +function extractTweetId(proofUrl: string): string | null { + const match = proofUrl.match(/\/status\/(\d+)/); + return match ? match[1] : null; +} diff --git a/packages/api-main/src/social/verify.ts b/packages/api-main/src/social/verify.ts new file mode 100644 index 00000000..0d165d4b --- /dev/null +++ b/packages/api-main/src/social/verify.ts @@ -0,0 +1,107 @@ +import process from 'node:process'; + +import { eq } from 'drizzle-orm'; + +import { getDatabase } from '../../drizzle/db'; +import { SocialLinksTable } from '../../drizzle/schema'; +import { SOCIAL_LINK_ERROR_REASON, SOCIAL_LINK_STATUS } from '../types/social-link'; +import { verifyXTweet } from './providers/x'; + +const MAX_RETRIES = 3; +const INITIAL_DELAY_MS = 2000; // 2 seconds, doubles each retry +const STATUS_VERIFIED = SOCIAL_LINK_STATUS.VERIFIED; +const STATUS_FAILED = SOCIAL_LINK_STATUS.FAILED; +const ERROR_REASON_PROOF_MISMATCH = SOCIAL_LINK_ERROR_REASON.PROOF_MISMATCH; +const ERROR_REASON_VERIFICATION_FAILED = SOCIAL_LINK_ERROR_REASON.VERIFICATION_FAILED; + +/** + * Fetches the proof link, verifies it according to the platform, and updates the DB record with the result. + * Never throws — all errors are caught and result in a 'failed' DB update. + */ +export async function verifyLink( + id: number, + platform: string, + proofUrl: string, + address: string, + username: string, +): Promise { + // Dev bypass: skip external verification and immediately mark as verified. + // Set SKIP_SOCIAL_VERIFICATION=true in .env to enable. + if (process.env.SKIP_SOCIAL_VERIFICATION === 'true') { + await getDatabase() + .update(SocialLinksTable) + .set({ status: STATUS_VERIFIED, error_reason: null }) + .where(eq(SocialLinksTable.id, id)); + return; + } + + let lastError: Error | null = null; + + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + // Retry with exponential delay + if (attempt > 0) { + const delayMs = INITIAL_DELAY_MS * 2 ** (attempt - 1); + await sleep(delayMs); + } + + try { + const verified = await verifyWithProvider(platform, proofUrl, address, username); + + if (verified) { + await getDatabase() + .update(SocialLinksTable) + .set({ status: STATUS_VERIFIED, error_reason: null }) + .where(eq(SocialLinksTable.id, id)); + return; + } + + // Proof check failed (wrong author or missing code) — no point retrying + await getDatabase() + .update(SocialLinksTable) + .set({ + status: STATUS_FAILED, + error_reason: ERROR_REASON_PROOF_MISMATCH, + }) + .where(eq(SocialLinksTable.id, id)); + return; + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + console.error(`verifyLink attempt ${attempt + 1}/${MAX_RETRIES} failed for id=${id}:`, lastError.message); + } + } + + // All retries exhausted — mark as failed + try { + await getDatabase() + .update(SocialLinksTable) + .set({ + status: STATUS_FAILED, + error_reason: ERROR_REASON_VERIFICATION_FAILED, + }) + .where(eq(SocialLinksTable.id, id)); + } catch (dbErr) { + console.error(`verifyLink: failed to update DB to failed status for id=${id}:`, dbErr); + } +} + +/** + * Selects the correct provider verify function based on platform. + * Currently only 'x' is supported; extend here to add new platforms. + */ +async function verifyWithProvider( + platform: string, + proofUrl: string, + address: string, + username: string, +): Promise { + switch (platform) { + case 'x': + return verifyXTweet(proofUrl, address, username); + default: + throw new Error(`Unsupported platform: ${platform}`); + } +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/packages/api-main/src/types/social-link.ts b/packages/api-main/src/types/social-link.ts new file mode 100644 index 00000000..b0c86e57 --- /dev/null +++ b/packages/api-main/src/types/social-link.ts @@ -0,0 +1,10 @@ +export const SOCIAL_LINK_STATUS = { + PENDING: 'pending', + VERIFIED: 'verified', + FAILED: 'failed', +} as const; + +export const SOCIAL_LINK_ERROR_REASON = { + PROOF_MISMATCH: 'proof_mismatch', + VERIFICATION_FAILED: 'verification_failed', +} as const; diff --git a/packages/frontend-main/package.json b/packages/frontend-main/package.json index 05bcffbc..8dfff25f 100644 --- a/packages/frontend-main/package.json +++ b/packages/frontend-main/package.json @@ -10,6 +10,7 @@ "test:e2e": "playwright test" }, "dependencies": { + "@atomone/dither-api-types": "workspace:*", "@cosmjs/amino": "^0.33.1", "@cosmjs/math": "^0.37.0", "@cosmjs/proto-signing": "^0.37.0", diff --git a/packages/frontend-main/src/composables/useSocialLinks.ts b/packages/frontend-main/src/composables/useSocialLinks.ts new file mode 100644 index 00000000..67db4615 --- /dev/null +++ b/packages/frontend-main/src/composables/useSocialLinks.ts @@ -0,0 +1,43 @@ +import type { Ref } from 'vue'; + +import { queryOptions, useQuery } from '@tanstack/vue-query'; + +import { useConfigStore } from '@/stores/useConfigStore'; + +export interface SocialLink { + id: number; + handle: string; + platform: string; + status: 'pending' | 'verified' | 'failed'; + error_reason?: string | null; + proof_url?: string; + created_at: string; +} + +interface Params { + address: Ref; +} + +export function socialLinks(params: Params) { + const configStore = useConfigStore(); + const apiRoot = configStore.envConfig.apiRoot ?? 'http://localhost:3000/v1'; + + return queryOptions({ + queryKey: ['social-links', params.address], + queryFn: async () => { + const res = await fetch( + `${apiRoot}/social/links?address=${params.address.value}`, + { credentials: 'include' }, + ); + if (!res.ok) throw new Error('Failed to fetch social links'); + const json = await res.json(); + return (json.rows ?? []) as SocialLink[]; + }, + enabled: () => !!params.address.value, + staleTime: 10_000, + }); +} + +export function useSocialLinks(params: Params) { + return useQuery(socialLinks(params)); +} diff --git a/packages/frontend-main/src/types/index.ts b/packages/frontend-main/src/types/index.ts index 88572f70..36bfe713 100644 --- a/packages/frontend-main/src/types/index.ts +++ b/packages/frontend-main/src/types/index.ts @@ -15,4 +15,6 @@ export interface DitherTypes { Dislike: [string]; // PostHash Flag: [string]; + // Username, Platform, ProofUrl + LinkSocial: [string, string, string]; }; diff --git a/packages/frontend-main/src/utility/social.ts b/packages/frontend-main/src/utility/social.ts new file mode 100644 index 00000000..359d6a6c --- /dev/null +++ b/packages/frontend-main/src/utility/social.ts @@ -0,0 +1,13 @@ +export { getSocialProofCode } from '@atomone/dither-api-types'; + +export function generateTweetText(proofCode: string): string { + return `I'm verifying my Dither.chat identity. + +Verification code: ${proofCode} + +@_Dither`; +} + +export function getTweetIntentUrl(text: string): string { + return `https://x.com/intent/tweet?text=${encodeURIComponent(text)}`; +} diff --git a/packages/frontend-main/src/views/Profile/ProfileViewWrapper.vue b/packages/frontend-main/src/views/Profile/ProfileViewWrapper.vue index 248aa7c0..bde4271c 100644 --- a/packages/frontend-main/src/views/Profile/ProfileViewWrapper.vue +++ b/packages/frontend-main/src/views/Profile/ProfileViewWrapper.vue @@ -1,8 +1,8 @@ + +
+ + + + +
+ +
+ + + + +
+
+ + @{{ xLink.handle.split('@')[0] }} +
+ +
+ + +
+
+ + @{{ xLink.handle.split('@')[0] }} +
+ +
+ +
+
+

+ Your verification code: +

+ {{ verificationCode }} +
+ +
+

+ Post this to X: +

+ + + Tweet it now + +
+ +
+

+ Paste your tweet URL: +

+ +
+ + +
+
+
+
+ diff --git a/packages/lib-api-types/src/gets/index.ts b/packages/lib-api-types/src/gets/index.ts index ac7df26d..5dc24d86 100644 --- a/packages/lib-api-types/src/gets/index.ts +++ b/packages/lib-api-types/src/gets/index.ts @@ -107,3 +107,13 @@ export const NotificationsCountQuerySchema = t.Object({ address: t.String(), }); export type NotificationsCountQuery = Static; + +export const SocialLinksQuerySchema = t.Object({ + address: t.String(), +}); +export type SocialLinksQuery = Static; + +export const SocialResolveQuerySchema = t.Object({ + handle: t.String(), +}); +export type SocialResolveQuery = Static; diff --git a/packages/lib-api-types/src/index.ts b/packages/lib-api-types/src/index.ts index 59e52de9..0214a65f 100644 --- a/packages/lib-api-types/src/index.ts +++ b/packages/lib-api-types/src/index.ts @@ -1,2 +1,3 @@ export * as Gets from './gets/index'; export * as Posts from './posts/index'; +export { getSocialProofCode } from './utils'; diff --git a/packages/lib-api-types/src/posts/index.ts b/packages/lib-api-types/src/posts/index.ts index 446dfca2..37dc4927 100644 --- a/packages/lib-api-types/src/posts/index.ts +++ b/packages/lib-api-types/src/posts/index.ts @@ -109,3 +109,13 @@ export const RemovePublicKeySchema = t.Object({ key: t.String(), }); export type RemovePublicKey = Static; + +export const SocialProofBodySchema = t.Object({ + hash: t.String(), + from: t.String(), + username: t.String(), + platform: t.String(), + proof_url: t.String(), + timestamp: t.String(), +}); +export type SocialProofBody = Static; diff --git a/packages/lib-api-types/src/utils.ts b/packages/lib-api-types/src/utils.ts new file mode 100644 index 00000000..a59f9069 --- /dev/null +++ b/packages/lib-api-types/src/utils.ts @@ -0,0 +1,15 @@ +/** + * Generates a deterministic verification code from a wallet address. + * Strips the prefix, then takesthe first 4 and last 4 characters of the remaining data part. + * + * Output format: "XXXX-YYYY" + * + * Used by both frontend and backend to ensure the code shown to the user + * matches the code verified against the proof tweet. + */ +export function getSocialProofCode(address: string): string { + const withoutPrefix = address.replace(/^[a-z]+1/, ''); + const first4 = withoutPrefix.slice(0, 4); + const last4 = withoutPrefix.slice(-4); + return `${first4}-${last4}`.toUpperCase(); +} diff --git a/packages/reader-main/src/messages/index.ts b/packages/reader-main/src/messages/index.ts index 74d530d4..200439b5 100644 --- a/packages/reader-main/src/messages/index.ts +++ b/packages/reader-main/src/messages/index.ts @@ -5,6 +5,7 @@ import { Like } from './like'; import { Post } from './post'; import { Remove } from './remove'; import { Reply } from './reply'; +import { LinkSocial } from './social'; import { Unfollow } from './unfollow'; export const MessageHandlers = { @@ -12,6 +13,7 @@ export const MessageHandlers = { Flag, Follow, Like, + LinkSocial, Post, Remove, Reply, diff --git a/packages/reader-main/src/messages/social.ts b/packages/reader-main/src/messages/social.ts new file mode 100644 index 00000000..f2e8a885 --- /dev/null +++ b/packages/reader-main/src/messages/social.ts @@ -0,0 +1,70 @@ +/* eslint-disable ts/no-namespace */ +import type { ActionWithData, ResponseStatus } from '../types/index'; + +import process from 'node:process'; + +import { extractMemoContent } from '@atomone/chronostate'; + +import { useConfig } from '../config/index'; + +declare module '@atomone/chronostate' { + export namespace MemoExtractor { + export interface TypeMap { + 'dither.LinkSocial': [string, string, string]; + } + } +} + +const { AUTH } = useConfig(); +const apiRoot = process.env.API_ROOT ?? 'http://localhost:3000/v1'; + +export async function LinkSocial(action: ActionWithData): Promise { + try { + const [username, platform, proofUrl] = extractMemoContent(action.memo, 'dither.LinkSocial'); + const postBody: { hash: string; from: string; username: string; platform: string; proof_url: string; timestamp: string } = { + hash: action.hash, + from: action.sender, + username, + platform, + proof_url: proofUrl, + timestamp: action.timestamp, + }; + + const rawResponse = await fetch(`${apiRoot}/social/proof`, { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'Authorization': AUTH, + }, + body: JSON.stringify(postBody), + }); + + if (rawResponse.status !== 200) { + console.error('Error posting to API:', rawResponse); + return 'RETRY'; + } + + const response = await rawResponse.json() as { status: number; error?: string }; + if (response.status === 200) { + console.log(`dither.LinkSocial message processed successfully: ${action.hash}`); + return 'SUCCESS'; + } + + if (response.status === 500) { + console.log(`dither.LinkSocial could not reach database: ${action.hash}`); + return 'RETRY'; + } + + if (response.status === 400) { + console.log(`dither.LinkSocial message skipped, ${response.error} | Hash: ${action.hash}`); + return 'SKIP'; + } + + console.warn(`dither.LinkSocial failed: ${response.error} | Hash: ${action.hash}`); + return 'RETRY'; + } catch (error) { + console.error('Error processing message:', error); + return 'RETRY'; + }; +} From ea1fbdb11dd896c8b6aa153ccffe60d444d55ce9 Mon Sep 17 00:00:00 2001 From: Lucio Rubens Date: Mon, 2 Mar 2026 18:41:04 -0300 Subject: [PATCH 2/6] refactor: better ui and github implementation --- packages/api-main/.env.example | 5 + packages/api-main/README.md | 20 ++ packages/api-main/src/gets/feed.ts | 7 +- packages/api-main/src/gets/notifications.ts | 7 +- packages/api-main/src/gets/posts.ts | 7 +- packages/api-main/src/gets/replies.ts | 7 +- packages/api-main/src/gets/social.ts | 35 ++++ packages/api-main/src/posts/social.ts | 2 +- .../api-main/src/social/providers/github.ts | 122 ++++++++++++ packages/api-main/src/social/verify.ts | 41 +++- packages/api-main/src/types/social-link.ts | 1 + .../api-main/tests/github-provider.test.ts | 132 +++++++++++++ .../src/chain-config.testnet.json | 6 +- .../src/components/posts/PostItem.vue | 2 +- .../src/components/ui/button/index.ts | 10 +- .../components/ui/copy/CopyToClipboard.vue | 95 +++++++++ .../src/components/ui/copy/index.ts | 1 + .../src/components/ui/tooltip/Tooltip.vue | 19 ++ .../components/ui/tooltip/TooltipContent.vue | 50 +++++ .../components/ui/tooltip/TooltipProvider.vue | 16 ++ .../components/ui/tooltip/TooltipTrigger.vue | 16 ++ .../src/components/ui/tooltip/index.ts | 4 + .../components/users/UserAvatarUsername.vue | 5 +- .../src/components/users/Username.vue | 49 ++++- .../frontend-main/src/composables/useFeed.ts | 5 + .../src/composables/useFollowingPosts.ts | 11 +- .../src/composables/useNotifications.ts | 11 +- .../src/composables/useReplies.ts | 11 +- .../social/components/SocialAccountsPanel.vue | 162 +++++++++++++++ .../social/components/SocialProviderRow.vue | 182 +++++++++++++++++ .../src/features/social/components/XIcon.vue | 9 + .../social/composables/useAddressHandle.ts | 19 ++ .../social}/composables/useSocialLinks.ts | 20 +- .../src/features/social/index.ts | 5 + .../src/features/social/providers/registry.ts | 65 ++++++ .../src/views/Profile/ProfileViewWrapper.vue | 186 ++++-------------- 36 files changed, 1164 insertions(+), 181 deletions(-) create mode 100644 packages/api-main/src/gets/social.ts create mode 100644 packages/api-main/src/social/providers/github.ts create mode 100644 packages/api-main/tests/github-provider.test.ts create mode 100644 packages/frontend-main/src/components/ui/copy/CopyToClipboard.vue create mode 100644 packages/frontend-main/src/components/ui/copy/index.ts create mode 100644 packages/frontend-main/src/components/ui/tooltip/Tooltip.vue create mode 100644 packages/frontend-main/src/components/ui/tooltip/TooltipContent.vue create mode 100644 packages/frontend-main/src/components/ui/tooltip/TooltipProvider.vue create mode 100644 packages/frontend-main/src/components/ui/tooltip/TooltipTrigger.vue create mode 100644 packages/frontend-main/src/components/ui/tooltip/index.ts create mode 100644 packages/frontend-main/src/features/social/components/SocialAccountsPanel.vue create mode 100644 packages/frontend-main/src/features/social/components/SocialProviderRow.vue create mode 100644 packages/frontend-main/src/features/social/components/XIcon.vue create mode 100644 packages/frontend-main/src/features/social/composables/useAddressHandle.ts rename packages/frontend-main/src/{ => features/social}/composables/useSocialLinks.ts (64%) create mode 100644 packages/frontend-main/src/features/social/index.ts create mode 100644 packages/frontend-main/src/features/social/providers/registry.ts diff --git a/packages/api-main/.env.example b/packages/api-main/.env.example index 406b44ca..cf23a35d 100644 --- a/packages/api-main/.env.example +++ b/packages/api-main/.env.example @@ -8,3 +8,8 @@ TELEGRAM_BOT_USERNAME=@bot_username TELEGRAM_FEED_CHANNEL_ID= TELEGRAM_FEED_CHANNEL_NAME==@channel_name TELEGRAM_MINIAPP_URL=https://dither.chat + +# Bypass social verification for development/testing purposes. Set to false in production. +SKIP_SOCIAL_VERIFICATION=false +X_BEARER_TOKEN=x_token +GITHUB_BEARER_TOKEN=github_token diff --git a/packages/api-main/README.md b/packages/api-main/README.md index 921f3e77..fe1d58f1 100644 --- a/packages/api-main/README.md +++ b/packages/api-main/README.md @@ -58,3 +58,23 @@ Run the Tests pnpm install pnpm test ``` + +## Social Verification + +Social username verification is asynchronous: + +1. Client submits a proof with `platform`, `username`, and `proof_url`. +2. API stores the link as `pending`. +3. API verifies the proof in background. +4. Link is updated to `verified` or `failed`. + +Verification checks: + +- `x`: proof URL must be a tweet URL. The tweet author must match the claimed username and the tweet text must contain the generated verification code. +- `github`: proof URL must be `https://gist.github.com/{username}/{gistid}`. The gist owner must match the claimed username and file `dither.md` must contain the generated verification code. + +For local development, you can bypass external checks by setting: + +``` +SKIP_SOCIAL_VERIFICATION=true +``` diff --git a/packages/api-main/src/gets/feed.ts b/packages/api-main/src/gets/feed.ts index 81f59779..49fd96ed 100644 --- a/packages/api-main/src/gets/feed.ts +++ b/packages/api-main/src/gets/feed.ts @@ -4,6 +4,7 @@ import { and, count, desc, gte, isNull, sql } from 'drizzle-orm'; import { getDatabase } from '../../drizzle/db'; import { FeedTable } from '../../drizzle/schema'; +import { fetchSocialByAddresses } from './social'; const statement = getDatabase() .select() @@ -48,7 +49,11 @@ export async function Feed(query: Gets.FeedQuery) { try { const results = await statement.execute({ offset, limit, minQuantity }); - return { status: 200, rows: results }; + if (results.length === 0) { + return { status: 200, rows: results }; + } + const social = await fetchSocialByAddresses(results.map(r => r.author)); + return { status: 200, rows: results, social }; } catch (error) { console.error(error); return { status: 400, error: 'failed to read data from database' }; diff --git a/packages/api-main/src/gets/notifications.ts b/packages/api-main/src/gets/notifications.ts index fed7f149..ebd7c64f 100644 --- a/packages/api-main/src/gets/notifications.ts +++ b/packages/api-main/src/gets/notifications.ts @@ -6,6 +6,7 @@ import { and, eq, sql } from 'drizzle-orm'; import { getDatabase } from '../../drizzle/db'; import { NotificationTable } from '../../drizzle/schema'; import { verifyJWT } from '../shared/jwt'; +import { fetchSocialByAddresses } from './social'; const getNotificationsStatement = getDatabase() .select() @@ -38,7 +39,11 @@ export async function Notifications(query: Gets.NotificationsQuery, auth: Cookie try { const results = await getNotificationsStatement.execute({ owner: response, limit, offset }); - return { status: 200, rows: results }; + if (results.length === 0) { + return { status: 200, rows: results }; + } + const social = await fetchSocialByAddresses(results.map(r => r.actor)); + return { status: 200, rows: results, social }; } catch (error) { console.error(error); return { status: 404, error: 'failed to find matching reply' }; diff --git a/packages/api-main/src/gets/posts.ts b/packages/api-main/src/gets/posts.ts index 9bccbc5e..4df12e87 100644 --- a/packages/api-main/src/gets/posts.ts +++ b/packages/api-main/src/gets/posts.ts @@ -4,6 +4,7 @@ import { and, desc, eq, gte, inArray, isNull, sql } from 'drizzle-orm'; import { getDatabase } from '../../drizzle/db'; import { FeedTable, FollowsTable } from '../../drizzle/schema'; +import { fetchSocialByAddresses } from './social'; const statement = getDatabase() .select() @@ -85,7 +86,11 @@ export async function FollowingPosts(query: Gets.PostsQuery) { try { const results = await followingPostsStatement.execute({ address: query.address, limit, offset, minQuantity }); - return { status: 200, rows: results }; + if (results.length === 0) { + return { status: 200, rows: results }; + } + const social = await fetchSocialByAddresses(results.map(r => r.author)); + return { status: 200, rows: results, social }; } catch (error) { console.error(error); return { status: 404, error: 'failed to posts from followed users' }; diff --git a/packages/api-main/src/gets/replies.ts b/packages/api-main/src/gets/replies.ts index 928471a3..4db19d12 100644 --- a/packages/api-main/src/gets/replies.ts +++ b/packages/api-main/src/gets/replies.ts @@ -5,6 +5,7 @@ import { alias } from 'drizzle-orm/pg-core'; import { getDatabase } from '../../drizzle/db'; import { FeedTable } from '../../drizzle/schema'; +import { fetchSocialByAddresses } from './social'; const statement = getDatabase() .select() @@ -40,7 +41,11 @@ export async function Replies(query: Gets.RepliesQuery) { try { const results = await statement.execute({ hash: query.hash, limit, offset, minQuantity }); - return { status: 200, rows: results }; + if (results.length === 0) { + return { status: 200, rows: results }; + } + const social = await fetchSocialByAddresses(results.map(r => r.author)); + return { status: 200, rows: results, social }; } catch (error) { console.error(error); return { status: 404, error: 'failed to find matching reply' }; diff --git a/packages/api-main/src/gets/social.ts b/packages/api-main/src/gets/social.ts new file mode 100644 index 00000000..0eb5f771 --- /dev/null +++ b/packages/api-main/src/gets/social.ts @@ -0,0 +1,35 @@ +import { and, eq, inArray } from 'drizzle-orm'; + +import { getDatabase } from '../../drizzle/db'; +import { SocialLinksTable } from '../../drizzle/schema'; +import { SOCIAL_LINK_STATUS } from '../types/social-link'; + +export type SocialByAddress = Record; + +/** + * Fetch verified social links for a list of addresses. + * Returns a Record keyed by address. Only includes addresses that have at least one verified link. + * Returns an empty object if addresses is empty. + */ +export async function fetchSocialByAddresses(addresses: string[]): Promise { + if (addresses.length === 0) return {}; + + const unique = [...new Set(addresses)]; + + const links = await getDatabase() + .select() + .from(SocialLinksTable) + .where( + and( + inArray(SocialLinksTable.address, unique), + eq(SocialLinksTable.status, SOCIAL_LINK_STATUS.VERIFIED), + ), + ); + + const result: SocialByAddress = {}; + for (const link of links) { + if (!result[link.address]) result[link.address] = []; + result[link.address].push(link); + } + return result; +} diff --git a/packages/api-main/src/posts/social.ts b/packages/api-main/src/posts/social.ts index ca24c0c5..4c61fc87 100644 --- a/packages/api-main/src/posts/social.ts +++ b/packages/api-main/src/posts/social.ts @@ -58,7 +58,7 @@ export async function SocialProof(body: Posts.SocialProofBody) { // Verification runs in background, we don't want to block the API response. // Future improvement: if we had a job queue system, we could push a job here instead of this. verifyLink(insertedId, body.platform.toLowerCase(), body.proof_url, body.from.toLowerCase(), body.username.toLowerCase()).catch( - err => console.error(`verifyLink fire-and-forget error for id=${insertedId}:`, err), + err => console.error(`verifySocialLink error for id=${insertedId}:`, err), ); return { status: 200 }; diff --git a/packages/api-main/src/social/providers/github.ts b/packages/api-main/src/social/providers/github.ts new file mode 100644 index 00000000..dcf0cd1e --- /dev/null +++ b/packages/api-main/src/social/providers/github.ts @@ -0,0 +1,122 @@ +import process from 'node:process'; + +import { getSocialProofCode } from '../code'; + +interface GitHubGistFile { + filename?: string; + type?: string; + language?: string | null; + raw_url?: string; + size?: number; + truncated?: boolean; + content?: string; +} + +interface GitHubGistResponse { + id?: string; + owner?: { + login?: string; + } | null; + files?: Record; +} + +const GITHUB_API_VERSION = '2022-11-28'; +const REQUIRED_PROOF_FILE = 'dither.md'; + +export async function verifyGitHubGist( + proofUrl: string, + address: string, + claimedUsername: string, +): Promise { + const gistId = extractGistId(proofUrl); + if (!gistId) { + throw new Error(`Cannot extract gist ID from proof URL: ${proofUrl}`); + } + + const gist = await fetchGist(gistId); + const gistOwnerUsername = gist.owner?.login; + const proofFile = gist.files?.[REQUIRED_PROOF_FILE]; + + const authorMatches = gistOwnerUsername?.toLowerCase() === claimedUsername.toLowerCase(); + if (!authorMatches || !proofFile) { + return false; + } + + const proofContent = await readGistFileContent(proofFile); + const expectedCode = getSocialProofCode(address); + const codePresent = proofContent.includes(expectedCode); + + return codePresent; +} + +async function fetchGist(gistId: string): Promise { + const response = await fetch(`https://api.github.com/gists/${gistId}`, { + headers: buildGitHubHeaders(), + }); + + if (!response.ok) { + throw new Error(`GitHub API returned ${response.status}: ${response.statusText}`); + } + + return await response.json() as GitHubGistResponse; +} + +async function readGistFileContent(file: GitHubGistFile): Promise { + if (typeof file.content === 'string' && !file.truncated) { + return file.content; + } + + // GitHub can truncate file content in GET /gists/{id}, so fallback to raw_url only in that case. + if (!file.raw_url) { + return file.content ?? ''; + } + + const response = await fetch(file.raw_url, { + headers: buildGitHubHeaders(), + }); + + if (!response.ok) { + throw new Error(`GitHub raw gist fetch returned ${response.status}: ${response.statusText}`); + } + + return await response.text(); +} + +function buildGitHubHeaders(): Record { + const headers: Record = { + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': GITHUB_API_VERSION, + 'User-Agent': 'dither-chat-verifier', + }; + + const token = process.env.GITHUB_BEARER_TOKEN; + if (!token) { + throw new Error('GITHUB_BEARER_TOKEN environment variable is not set'); + } + + headers.Authorization = `Bearer ${token}`; + return headers; +} + +/** + * Expected format: + * - https://gist.github.com/username/gistid + */ +function extractGistId(proofUrl: string): string | null { + try { + const url = new URL(proofUrl); + if (url.protocol !== 'https:' || url.hostname !== 'gist.github.com') { + return null; + } + + const segments = url.pathname + .split('/') + .filter(Boolean); + + if (segments.length !== 2) return null; + const gistId = segments[1] ?? ''; + return gistId.length > 0 ? gistId : null; + } catch { + return null; + } +} diff --git a/packages/api-main/src/social/verify.ts b/packages/api-main/src/social/verify.ts index 0d165d4b..86486d70 100644 --- a/packages/api-main/src/social/verify.ts +++ b/packages/api-main/src/social/verify.ts @@ -1,10 +1,11 @@ import process from 'node:process'; -import { eq } from 'drizzle-orm'; +import { and, eq, ne } from 'drizzle-orm'; import { getDatabase } from '../../drizzle/db'; import { SocialLinksTable } from '../../drizzle/schema'; import { SOCIAL_LINK_ERROR_REASON, SOCIAL_LINK_STATUS } from '../types/social-link'; +import { verifyGitHubGist } from './providers/github'; import { verifyXTweet } from './providers/x'; const MAX_RETRIES = 3; @@ -13,6 +14,7 @@ const STATUS_VERIFIED = SOCIAL_LINK_STATUS.VERIFIED; const STATUS_FAILED = SOCIAL_LINK_STATUS.FAILED; const ERROR_REASON_PROOF_MISMATCH = SOCIAL_LINK_ERROR_REASON.PROOF_MISMATCH; const ERROR_REASON_VERIFICATION_FAILED = SOCIAL_LINK_ERROR_REASON.VERIFICATION_FAILED; +const ERROR_REASON_HANDLE_ALREADY_CLAIMED = SOCIAL_LINK_ERROR_REASON.HANDLE_ALREADY_CLAIMED; /** * Fetches the proof link, verifies it according to the platform, and updates the DB record with the result. @@ -28,6 +30,14 @@ export async function verifyLink( // Dev bypass: skip external verification and immediately mark as verified. // Set SKIP_SOCIAL_VERIFICATION=true in .env to enable. if (process.env.SKIP_SOCIAL_VERIFICATION === 'true') { + if (await isHandleAlreadyClaimed(username, platform, id)) { + await getDatabase() + .update(SocialLinksTable) + .set({ status: STATUS_FAILED, error_reason: ERROR_REASON_HANDLE_ALREADY_CLAIMED }) + .where(eq(SocialLinksTable.id, id)); + return; + } + await getDatabase() .update(SocialLinksTable) .set({ status: STATUS_VERIFIED, error_reason: null }) @@ -48,6 +58,14 @@ export async function verifyLink( const verified = await verifyWithProvider(platform, proofUrl, address, username); if (verified) { + if (await isHandleAlreadyClaimed(username, platform, id)) { + await getDatabase() + .update(SocialLinksTable) + .set({ status: STATUS_FAILED, error_reason: ERROR_REASON_HANDLE_ALREADY_CLAIMED }) + .where(eq(SocialLinksTable.id, id)); + return; + } + await getDatabase() .update(SocialLinksTable) .set({ status: STATUS_VERIFIED, error_reason: null }) @@ -86,7 +104,6 @@ export async function verifyLink( /** * Selects the correct provider verify function based on platform. - * Currently only 'x' is supported; extend here to add new platforms. */ async function verifyWithProvider( platform: string, @@ -97,11 +114,31 @@ async function verifyWithProvider( switch (platform) { case 'x': return verifyXTweet(proofUrl, address, username); + case 'github': + return verifyGitHubGist(proofUrl, address, username); default: throw new Error(`Unsupported platform: ${platform}`); } } +// Checks if the given handle is already claimed by another user (excluding the current record ID) +async function isHandleAlreadyClaimed(username: string, platform: string, excludeId: number): Promise { + const handle = `${username}@${platform}`; + const existing = await getDatabase() + .select({ id: SocialLinksTable.id }) + .from(SocialLinksTable) + .where( + and( + eq(SocialLinksTable.handle, handle), + eq(SocialLinksTable.platform, platform), + eq(SocialLinksTable.status, STATUS_VERIFIED), + ne(SocialLinksTable.id, excludeId), + ), + ) + .limit(1); + return existing.length > 0; +} + function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } diff --git a/packages/api-main/src/types/social-link.ts b/packages/api-main/src/types/social-link.ts index b0c86e57..f333eaba 100644 --- a/packages/api-main/src/types/social-link.ts +++ b/packages/api-main/src/types/social-link.ts @@ -7,4 +7,5 @@ export const SOCIAL_LINK_STATUS = { export const SOCIAL_LINK_ERROR_REASON = { PROOF_MISMATCH: 'proof_mismatch', VERIFICATION_FAILED: 'verification_failed', + HANDLE_ALREADY_CLAIMED: 'handle_already_claimed', } as const; diff --git a/packages/api-main/tests/github-provider.test.ts b/packages/api-main/tests/github-provider.test.ts new file mode 100644 index 00000000..48d77ed9 --- /dev/null +++ b/packages/api-main/tests/github-provider.test.ts @@ -0,0 +1,132 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { getSocialProofCode } from '../src/social/code'; +import { verifyGitHubGist } from '../src/social/providers/github'; + +const address = 'atone1qgdjh6zvzzc8gv3fzk0mu9mezh6gan9hsaxyz1'; +const username = 'dither'; +const gistId = 'abcdef1234567890'; +const proofUrl = `https://gist.github.com/${username}/${gistId}`; +const originalFetch = globalThis.fetch; + +function jsonResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { + 'content-type': 'application/json', + }, + }); +} + +function textResponse(body: string, status = 200) { + return new Response(body, { status }); +} + +afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); +}); + +describe('verifyGitHubGist', () => { + it('verifies when owner matches and dither.md contains proof code', async () => { + const proofCode = getSocialProofCode(address); + const fetchMock = vi.fn().mockResolvedValueOnce(jsonResponse({ + owner: { login: username }, + files: { + 'dither.md': { + filename: 'dither.md', + truncated: false, + content: `verification: ${proofCode}`, + }, + }, + })); + + globalThis.fetch = fetchMock as typeof fetch; + + await expect(verifyGitHubGist(proofUrl, address, username)).resolves.toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + `https://api.github.com/gists/${gistId}`, + expect.objectContaining({ + headers: expect.objectContaining({ + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }), + }), + ); + }); + + it('returns false when gist owner does not match claimed username', async () => { + const fetchMock = vi.fn().mockResolvedValueOnce(jsonResponse({ + owner: { login: 'bob' }, + files: { + 'dither.md': { + truncated: false, + content: `verification: ${getSocialProofCode(address)}`, + }, + }, + })); + globalThis.fetch = fetchMock as typeof fetch; + + await expect(verifyGitHubGist(proofUrl, address, username)).resolves.toBe(false); + }); + + it('returns false when dither.md is missing', async () => { + const fetchMock = vi.fn().mockResolvedValueOnce(jsonResponse({ + owner: { login: username }, + files: { + 'readme.md': { + truncated: false, + content: `verification: ${getSocialProofCode(address)}`, + }, + }, + })); + globalThis.fetch = fetchMock as typeof fetch; + + await expect(verifyGitHubGist(proofUrl, address, username)).resolves.toBe(false); + }); + + it('fetches raw_url when dither.md content is truncated', async () => { + const proofCode = getSocialProofCode(address); + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse({ + owner: { login: username }, + files: { + 'dither.md': { + filename: 'dither.md', + truncated: true, + content: 'partial...', + raw_url: `https://gist.githubusercontent.com/${username}/${gistId}/raw/dither.md`, + }, + }, + })) + .mockResolvedValueOnce(textResponse(`full content ${proofCode}`)); + globalThis.fetch = fetchMock as typeof fetch; + + await expect(verifyGitHubGist(proofUrl, address, username)).resolves.toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + `https://gist.githubusercontent.com/${username}/${gistId}/raw/dither.md`, + expect.any(Object), + ); + }); + + it('rejects invalid proof URL format', async () => { + const fetchMock = vi.fn(); + globalThis.fetch = fetchMock as typeof fetch; + + await expect(verifyGitHubGist(`https://gist.github.com/${gistId}`, address, username)) + .rejects + .toThrow('Cannot extract gist ID'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('throws when GitHub API returns non-200', async () => { + const fetchMock = vi.fn().mockResolvedValueOnce(jsonResponse({ message: 'not found' }, 404)); + globalThis.fetch = fetchMock as typeof fetch; + + await expect(verifyGitHubGist(proofUrl, address, username)).rejects.toThrow('GitHub API returned 404'); + }); +}); diff --git a/packages/frontend-main/src/chain-config.testnet.json b/packages/frontend-main/src/chain-config.testnet.json index cc61fabc..ae8db795 100644 --- a/packages/frontend-main/src/chain-config.testnet.json +++ b/packages/frontend-main/src/chain-config.testnet.json @@ -1,7 +1,7 @@ { - "rpc": "https://atomone-testnet-1-rpc.allinbits.services/", - "rest": "https://atomone-testnet-1-api.allinbits.services/", - "chainId": "atomone-testnet-1", + "rpc": "http://localhost:26657/", + "rest": "http://localhost:1317/", + "chainId": "localnet", "chainName": "AtomOne", "bip44": { "coinType": 118 diff --git a/packages/frontend-main/src/components/posts/PostItem.vue b/packages/frontend-main/src/components/posts/PostItem.vue index b51d17ee..39e6bcad 100644 --- a/packages/frontend-main/src/components/posts/PostItem.vue +++ b/packages/frontend-main/src/components/posts/PostItem.vue @@ -36,7 +36,7 @@ const usedPost = computed(() => cachedPost.value || props.post); -
+
diff --git a/packages/frontend-main/src/components/ui/button/index.ts b/packages/frontend-main/src/components/ui/button/index.ts index 8fa69a75..e0d2d23a 100644 --- a/packages/frontend-main/src/components/ui/button/index.ts +++ b/packages/frontend-main/src/components/ui/button/index.ts @@ -5,7 +5,7 @@ import { cva } from 'class-variance-authority'; export { default as Button } from './Button.vue'; export const buttonVariants = cva( - 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xs text-base font-semibold transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*=\'size-\'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive', + 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xs font-semibold transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*=\'size-\'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive', { variants: { variant: { @@ -22,10 +22,10 @@ export const buttonVariants = cva( link: 'text-primary underline-offset-4 hover:underline active:underline', }, size: { - default: 'h-13 px-4 has-[>svg]:px-3', - xs: 'h-8 rounded-xs gap-1 px-2 has-[>svg]:px-1.5', - sm: 'h-10 rounded-xs gap-1.5 px-3 has-[>svg]:px-2.5', - lg: 'h-15 rounded-xs px-6 has-[>svg]:px-4', + default: 'h-13 text-base px-4 has-[>svg]:px-3', + xs: 'h-8 text-xs rounded-xs gap-1 px-2 has-[>svg]:px-1.5', + sm: 'h-9 text-sm rounded-xs gap-1.5 px-3 has-[>svg]:px-2.5', + lg: 'h-15 text-base rounded-xs px-6 has-[>svg]:px-4', icon: 'size-10', }, }, diff --git a/packages/frontend-main/src/components/ui/copy/CopyToClipboard.vue b/packages/frontend-main/src/components/ui/copy/CopyToClipboard.vue new file mode 100644 index 00000000..e65af2d7 --- /dev/null +++ b/packages/frontend-main/src/components/ui/copy/CopyToClipboard.vue @@ -0,0 +1,95 @@ + + + diff --git a/packages/frontend-main/src/components/ui/copy/index.ts b/packages/frontend-main/src/components/ui/copy/index.ts new file mode 100644 index 00000000..2322ee01 --- /dev/null +++ b/packages/frontend-main/src/components/ui/copy/index.ts @@ -0,0 +1 @@ +export { default as CopyToClipboard } from './CopyToClipboard.vue'; diff --git a/packages/frontend-main/src/components/ui/tooltip/Tooltip.vue b/packages/frontend-main/src/components/ui/tooltip/Tooltip.vue new file mode 100644 index 00000000..bbb6206e --- /dev/null +++ b/packages/frontend-main/src/components/ui/tooltip/Tooltip.vue @@ -0,0 +1,19 @@ + + + diff --git a/packages/frontend-main/src/components/ui/tooltip/TooltipContent.vue b/packages/frontend-main/src/components/ui/tooltip/TooltipContent.vue new file mode 100644 index 00000000..e0a0bbe3 --- /dev/null +++ b/packages/frontend-main/src/components/ui/tooltip/TooltipContent.vue @@ -0,0 +1,50 @@ + + + diff --git a/packages/frontend-main/src/components/ui/tooltip/TooltipProvider.vue b/packages/frontend-main/src/components/ui/tooltip/TooltipProvider.vue new file mode 100644 index 00000000..81db966f --- /dev/null +++ b/packages/frontend-main/src/components/ui/tooltip/TooltipProvider.vue @@ -0,0 +1,16 @@ + + + diff --git a/packages/frontend-main/src/components/ui/tooltip/TooltipTrigger.vue b/packages/frontend-main/src/components/ui/tooltip/TooltipTrigger.vue new file mode 100644 index 00000000..a99008ef --- /dev/null +++ b/packages/frontend-main/src/components/ui/tooltip/TooltipTrigger.vue @@ -0,0 +1,16 @@ + + + diff --git a/packages/frontend-main/src/components/ui/tooltip/index.ts b/packages/frontend-main/src/components/ui/tooltip/index.ts new file mode 100644 index 00000000..94e681c8 --- /dev/null +++ b/packages/frontend-main/src/components/ui/tooltip/index.ts @@ -0,0 +1,4 @@ +export { default as Tooltip } from './Tooltip.vue'; +export { default as TooltipContent } from './TooltipContent.vue'; +export { default as TooltipProvider } from './TooltipProvider.vue'; +export { default as TooltipTrigger } from './TooltipTrigger.vue'; diff --git a/packages/frontend-main/src/components/users/UserAvatarUsername.vue b/packages/frontend-main/src/components/users/UserAvatarUsername.vue index 5e50e795..d23e439f 100644 --- a/packages/frontend-main/src/components/users/UserAvatarUsername.vue +++ b/packages/frontend-main/src/components/users/UserAvatarUsername.vue @@ -16,6 +16,9 @@ const props = defineProps(); diff --git a/packages/frontend-main/src/components/users/Username.vue b/packages/frontend-main/src/components/users/Username.vue index 18cc59d2..306e1874 100644 --- a/packages/frontend-main/src/components/users/Username.vue +++ b/packages/frontend-main/src/components/users/Username.vue @@ -1,12 +1,57 @@ diff --git a/packages/frontend-main/src/composables/useFeed.ts b/packages/frontend-main/src/composables/useFeed.ts index 838b08fa..c9fdebbf 100644 --- a/packages/frontend-main/src/composables/useFeed.ts +++ b/packages/frontend-main/src/composables/useFeed.ts @@ -7,6 +7,7 @@ import { postSchema } from 'api-main/types/feed'; import { storeToRefs } from 'pinia'; import { ref } from 'vue'; +import { hydrateSocialLinks } from '@/features/social'; import { useConfigStore } from '@/stores/useConfigStore'; import { useFiltersStore } from '@/stores/useFiltersStore'; import { checkRowsSchema } from '@/utility/sanitize'; @@ -39,6 +40,10 @@ export function feed(queryClient: QueryClient) { queryClient.setQueryData(postOpts.queryKey, row); }); + if (json.social && typeof json.social === 'object') { + hydrateSocialLinks(queryClient, json.social); + } + return checkedRows; }, initialPageParam: 0, diff --git a/packages/frontend-main/src/composables/useFollowingPosts.ts b/packages/frontend-main/src/composables/useFollowingPosts.ts index 5e94cb5e..ac948837 100644 --- a/packages/frontend-main/src/composables/useFollowingPosts.ts +++ b/packages/frontend-main/src/composables/useFollowingPosts.ts @@ -1,3 +1,4 @@ +import type { QueryClient } from '@tanstack/vue-query'; import type { Post } from 'api-main/types/feed'; import type { Ref } from 'vue'; @@ -5,6 +6,7 @@ import { infiniteQueryOptions, useInfiniteQuery, useQueryClient } from '@tanstac import { postSchema } from 'api-main/types/feed'; import { ref } from 'vue'; +import { hydrateSocialLinks } from '@/features/social'; import { useConfigStore } from '@/stores/useConfigStore'; import { checkRowsSchema } from '@/utility/sanitize'; @@ -16,14 +18,13 @@ interface Params { userAddress: Ref; } -export function followingPosts(params: Params) { +export function followingPosts(params: Params, queryClient: QueryClient) { const configStore = useConfigStore(); const apiRoot = configStore.envConfig.apiRoot ?? 'http://localhost:3000/v1'; return infiniteQueryOptions({ queryKey: ['following-posts', params.userAddress], queryFn: async ({ pageParam = 0 }) => { - const queryClient = useQueryClient(); const res = await fetch(`${apiRoot}/following-posts?address=${params.userAddress.value}&offset=${pageParam}&limit=${LIMIT}`); const json = await res.json(); @@ -36,6 +37,10 @@ export function followingPosts(params: Params) { queryClient.setQueryData(postOpts.queryKey, row); }); + if (json.social && typeof json.social === 'object') { + hydrateSocialLinks(queryClient, json.social); + } + return checkedRows; }, initialPageParam: 0, @@ -50,5 +55,5 @@ export function followingPosts(params: Params) { } export function useFollowingPosts(params: Params) { - return useInfiniteQuery(followingPosts(params)); + return useInfiniteQuery(followingPosts(params, useQueryClient())); } diff --git a/packages/frontend-main/src/composables/useNotifications.ts b/packages/frontend-main/src/composables/useNotifications.ts index 7b12ad23..db25fb01 100644 --- a/packages/frontend-main/src/composables/useNotifications.ts +++ b/packages/frontend-main/src/composables/useNotifications.ts @@ -1,9 +1,11 @@ +import type { QueryClient } from '@tanstack/vue-query'; import type { Notification } from 'api-main/types/notifications'; import type { Ref } from 'vue'; -import { infiniteQueryOptions, useInfiniteQuery } from '@tanstack/vue-query'; +import { infiniteQueryOptions, useInfiniteQuery, useQueryClient } from '@tanstack/vue-query'; import { notificationSchema } from 'api-main/types/notifications'; +import { hydrateSocialLinks } from '@/features/social'; import { useConfigStore } from '@/stores/useConfigStore'; import { checkRowsSchema } from '@/utility/sanitize'; @@ -13,7 +15,7 @@ interface Params { userAddress: Ref; } -export function notifications(params: Params) { +export function notifications(params: Params, queryClient: QueryClient) { const configStore = useConfigStore(); const apiRoot = configStore.envConfig.apiRoot ?? 'http://localhost:3000/v1'; @@ -29,6 +31,9 @@ export function notifications(params: Params) { const json = await res.json(); // Check if the fetched rows match the notification schema const checkedRows: Notification[] = checkRowsSchema(notificationSchema, json.rows ?? []); + if (json.social && typeof json.social === 'object') { + hydrateSocialLinks(queryClient, json.social); + } return checkedRows; }, initialPageParam: 0, @@ -43,5 +48,5 @@ export function notifications(params: Params) { } export function useNotifications(params: Params) { - return useInfiniteQuery(notifications(params)); + return useInfiniteQuery(notifications(params, useQueryClient())); } diff --git a/packages/frontend-main/src/composables/useReplies.ts b/packages/frontend-main/src/composables/useReplies.ts index 2c8f14c1..b7789fec 100644 --- a/packages/frontend-main/src/composables/useReplies.ts +++ b/packages/frontend-main/src/composables/useReplies.ts @@ -1,3 +1,4 @@ +import type { QueryClient } from '@tanstack/vue-query'; import type { Post } from 'api-main/types/feed'; import type { Ref } from 'vue'; @@ -7,6 +8,7 @@ import { postSchema } from 'api-main/types/feed'; import { storeToRefs } from 'pinia'; import { ref } from 'vue'; +import { hydrateSocialLinks } from '@/features/social'; import { useConfigStore } from '@/stores/useConfigStore'; import { useFiltersStore } from '@/stores/useFiltersStore'; import { checkRowsSchema } from '@/utility/sanitize'; @@ -19,7 +21,7 @@ interface Params { hash: Ref; } -export function replies(params: Params) { +export function replies(params: Params, queryClient: QueryClient) { const configStore = useConfigStore(); const apiRoot = configStore.envConfig.apiRoot ?? 'http://localhost:3000/v1'; @@ -28,7 +30,6 @@ export function replies(params: Params) { return infiniteQueryOptions({ queryKey: ['replies', params.hash, debouncedFilterAmount], queryFn: async ({ pageParam = 0 }) => { - const queryClient = useQueryClient(); const res = await fetch(`${apiRoot}/replies?hash=${params.hash.value}&offset=${pageParam}&limit=${LIMIT}&minQuantity=${debouncedFilterAmount.value}`); const json = await res.json(); @@ -41,6 +42,10 @@ export function replies(params: Params) { queryClient.setQueryData(postOpts.queryKey, row); }); + if (json.social && typeof json.social === 'object') { + hydrateSocialLinks(queryClient, json.social); + } + return checkedRows; }, initialPageParam: 0, @@ -55,5 +60,5 @@ export function replies(params: Params) { } export function useReplies(params: Params) { - return useInfiniteQuery(replies(params)); + return useInfiniteQuery(replies(params, useQueryClient())); } diff --git a/packages/frontend-main/src/features/social/components/SocialAccountsPanel.vue b/packages/frontend-main/src/features/social/components/SocialAccountsPanel.vue new file mode 100644 index 00000000..011768ca --- /dev/null +++ b/packages/frontend-main/src/features/social/components/SocialAccountsPanel.vue @@ -0,0 +1,162 @@ + + + diff --git a/packages/frontend-main/src/features/social/components/SocialProviderRow.vue b/packages/frontend-main/src/features/social/components/SocialProviderRow.vue new file mode 100644 index 00000000..03a9d2b6 --- /dev/null +++ b/packages/frontend-main/src/features/social/components/SocialProviderRow.vue @@ -0,0 +1,182 @@ + + + diff --git a/packages/frontend-main/src/features/social/components/XIcon.vue b/packages/frontend-main/src/features/social/components/XIcon.vue new file mode 100644 index 00000000..e68ea37d --- /dev/null +++ b/packages/frontend-main/src/features/social/components/XIcon.vue @@ -0,0 +1,9 @@ + + + diff --git a/packages/frontend-main/src/features/social/composables/useAddressHandle.ts b/packages/frontend-main/src/features/social/composables/useAddressHandle.ts new file mode 100644 index 00000000..9d91c07b --- /dev/null +++ b/packages/frontend-main/src/features/social/composables/useAddressHandle.ts @@ -0,0 +1,19 @@ +import type { ComputedRef, Ref } from 'vue'; + +import { computed } from 'vue'; + +import { useSocialLinks } from './useSocialLinks'; + +export function useAddressHandle(address: Ref): ComputedRef { + const { data } = useSocialLinks({ address }); + + return computed(() => { + const links = data.value ?? []; + const verified = links + .filter(l => l.status === 'verified') + .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); + + if (!verified.length) return null; + return verified[0].handle; + }); +} diff --git a/packages/frontend-main/src/composables/useSocialLinks.ts b/packages/frontend-main/src/features/social/composables/useSocialLinks.ts similarity index 64% rename from packages/frontend-main/src/composables/useSocialLinks.ts rename to packages/frontend-main/src/features/social/composables/useSocialLinks.ts index 67db4615..3941f37d 100644 --- a/packages/frontend-main/src/composables/useSocialLinks.ts +++ b/packages/frontend-main/src/features/social/composables/useSocialLinks.ts @@ -1,3 +1,4 @@ +import type { QueryClient } from '@tanstack/vue-query'; import type { Ref } from 'vue'; import { queryOptions, useQuery } from '@tanstack/vue-query'; @@ -34,10 +35,23 @@ export function socialLinks(params: Params) { return (json.rows ?? []) as SocialLink[]; }, enabled: () => !!params.address.value, - staleTime: 10_000, + staleTime: Infinity, }); } -export function useSocialLinks(params: Params) { - return useQuery(socialLinks(params)); +export function hydrateSocialLinks(queryClient: QueryClient, social: Record) { + for (const [address, links] of Object.entries(social)) { + queryClient.setQueryData(['social-links', address], links); + } +} + +interface UseSocialLinksOptions extends Params { + refetchInterval?: Ref; +} + +export function useSocialLinks(options: UseSocialLinksOptions) { + return useQuery({ + ...socialLinks(options), + refetchInterval: options.refetchInterval, + }); } diff --git a/packages/frontend-main/src/features/social/index.ts b/packages/frontend-main/src/features/social/index.ts new file mode 100644 index 00000000..970d8fdb --- /dev/null +++ b/packages/frontend-main/src/features/social/index.ts @@ -0,0 +1,5 @@ +export { useAddressHandle } from './composables/useAddressHandle'; +export { hydrateSocialLinks, socialLinks, useSocialLinks } from './composables/useSocialLinks'; +export type { SocialLink } from './composables/useSocialLinks'; +export { providers } from './providers/registry'; +export type { SocialProvider } from './providers/registry'; diff --git a/packages/frontend-main/src/features/social/providers/registry.ts b/packages/frontend-main/src/features/social/providers/registry.ts new file mode 100644 index 00000000..49a5b4b0 --- /dev/null +++ b/packages/frontend-main/src/features/social/providers/registry.ts @@ -0,0 +1,65 @@ +import type { Component } from 'vue'; + +import { Github } from 'lucide-vue-next'; + +import XIcon from '../components/XIcon.vue'; + +export interface SocialProvider { + id: string; + label: string; + icon: Component; + extractUsername: (url: string) => string | null; + profileUrl: (handle: string) => string; + helpUrl: (verificationCode: string) => string; + helpLabel: string; + instructionsHtml: string; + proofContent: (verificationCode: string) => string; + proofPlaceholder: string; +} + +const xProvider: SocialProvider = { + id: 'x', + label: 'X', + icon: XIcon, + extractUsername(url: string): string | null { + const match = url.match(/^https:\/\/(?:www\.)?x\.com\/([^/]+)\/status\/\d+\/?$/i); + return match ? (match[1] ?? null) : null; + }, + profileUrl(handle: string): string { + return `https://x.com/${handle}`; + }, + helpUrl(verificationCode: string): string { + const text = `I'm verifying my Dither.chat identity.\n\nVerification code: ${verificationCode}\n\n@_Dither`; + return `https://x.com/intent/tweet?text=${encodeURIComponent(text)}`; + }, + helpLabel: 'Tweet it now', + instructionsHtml: 'Please tweet the text below exactly as it appears.', + proofContent(verificationCode: string): string { + return `I'm verifying my Dither.chat identity.\n\nVerification code: ${verificationCode}\n\n@_Dither`; + }, + proofPlaceholder: 'https://x.com/yourhandle/status/...', +}; + +const githubProvider: SocialProvider = { + id: 'github', + label: 'GitHub', + icon: Github, + extractUsername(url: string): string | null { + const match = url.match(/^https:\/\/gist\.github\.com\/([^/]+)\/([a-z0-9]+)\/?$/i); + return match ? (match[1] ?? null) : null; + }, + profileUrl(handle: string): string { + return `https://github.com/${handle}`; + }, + helpUrl(_verificationCode: string): string { + return 'https://gist.github.com/'; + }, + helpLabel: 'Create a gist', + instructionsHtml: 'Create a public gist with a file named dither.md and paste the content below exactly as it appears.', + proofContent(verificationCode: string): string { + return `# Dither.chat Verification\n\nI am verifying my Dither.chat identity.\n\nVerification code: ${verificationCode}`; + }, + proofPlaceholder: 'https://gist.github.com/yourhandle/yourgistid', +}; + +export const providers: SocialProvider[] = [xProvider, githubProvider]; diff --git a/packages/frontend-main/src/views/Profile/ProfileViewWrapper.vue b/packages/frontend-main/src/views/Profile/ProfileViewWrapper.vue index bde4271c..9ffb9f67 100644 --- a/packages/frontend-main/src/views/Profile/ProfileViewWrapper.vue +++ b/packages/frontend-main/src/views/Profile/ProfileViewWrapper.vue @@ -1,27 +1,30 @@