Skip to content
Open
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
1 change: 1 addition & 0 deletions bun.lock

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

5 changes: 5 additions & 0 deletions packages/api-main/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
33 changes: 33 additions & 0 deletions packages/api-main/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,36 @@ Run the Tests
pnpm install
pnpm test
```

## Social Verification

Social username verification is asynchronous:
Comment on lines +62 to +64
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
## Social Verification
Social username verification is asynchronous:
## Social Verification
Verification is done to link an AtomOne address to one or more social account usernames.
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 (with retries).
4. Link is updated to `verified` or `failed`.

### Providers

**X (Twitter)**

Uses [X API v2](https://developer.x.com/en/docs/x-api) to fetch tweet data. The proof URL must be a tweet URL (e.g., `https://x.com/username/status/123`). Verification checks:

- Tweet author matches the claimed username
- Tweet text contains the verification code

**GitHub**

Uses [GitHub REST API](https://docs.github.com/en/rest) to fetch gist data. The proof URL must be a gist URL (e.g., `https://gist.github.com/username/gistid`). Verification checks:

- Gist owner matches the claimed username
- File `dither.md` exists and contains the verification code

### Environment Variables

| Variable | Description |
| -------------------------- | -------------------------------------------------- |
| `X_BEARER_TOKEN` | Bearer token for X API authentication |
| `GITHUB_BEARER_TOKEN` | Personal access token for GitHub API |
| `SKIP_SOCIAL_VERIFICATION` | Set to `true` to bypass external checks (dev only) |
23 changes: 23 additions & 0 deletions packages/api-main/drizzle/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
{
Expand All @@ -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',
Expand All @@ -166,4 +188,5 @@ export const tables = [
'state',
'authrequests',
'ratelimits',
'social_links',
];
7 changes: 6 additions & 1 deletion packages/api-main/src/gets/feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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' };
Expand Down
2 changes: 2 additions & 0 deletions packages/api-main/src/gets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,5 @@ export * from './post';
export * from './posts';
export * from './replies';
export * from './search';
export * from './socialLinks';
export * from './socialResolve';
7 changes: 6 additions & 1 deletion packages/api-main/src/gets/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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' };
Expand Down
7 changes: 6 additions & 1 deletion packages/api-main/src/gets/posts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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' };
Expand Down
7 changes: 6 additions & 1 deletion packages/api-main/src/gets/replies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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' };
Expand Down
35 changes: 35 additions & 0 deletions packages/api-main/src/gets/social.ts
Original file line number Diff line number Diff line change
@@ -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<string, typeof SocialLinksTable.$inferSelect[]>;

/**
* 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<SocialByAddress> {
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;
}
75 changes: 75 additions & 0 deletions packages/api-main/src/gets/socialLinks.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Comment on lines +27 to +29
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe?

Suggested change
if (tokenAddress && tokenAddress.toLowerCase() === address) {
isOwner = true;
}
isOwner = (tokenAddress && tokenAddress.toLowerCase() === address)

}

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' };
}
}
53 changes: 53 additions & 0 deletions packages/api-main/src/gets/socialResolve.ts
Original file line number Diff line number Diff line change
@@ -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' };
}
}
1 change: 1 addition & 0 deletions packages/api-main/src/posts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading
Loading