merge: combine 10-layer + Jaccard deduplication
Some checks are pending
DegenFeed CI/CD / test (push) Waiting to run
DegenFeed CI/CD / deploy-worker (push) Blocked by required conditions
DegenFeed CI/CD / deploy-web (push) Blocked by required conditions

Merge df5db30 (5-layer Jaccard dedup + crossPostedTo) into b9f61bb (10-layer dedup).

Combined approach:
- 10-layer dedup (canonical ID, fingerprint, normalized text, URL, prefix,
  short exact, author window, quoted, ticker snapshot, embed)
- Jaccard word-set similarity check (from remote, threshold 0.7)
- crossPostedTo enrichment on matched posts
- Quotes/reposts skip Jaccard to avoid false dedup
- extractQuotedId returns text fallback when RT/@via has no URLs

19 tests pass, lint + typecheck clean.
This commit is contained in:
Crypto Rug Munch 2026-07-09 00:30:55 +07:00
commit b665c51f4d
29 changed files with 849 additions and 26 deletions

View file

@ -0,0 +1,31 @@
export async function onRequest(context) {
const { request, next } = context;
const url = new URL(request.url);
const { pathname } = url;
if (
pathname.startsWith("/_next/") ||
pathname.startsWith("/api/") ||
pathname.startsWith("/.well-known/") ||
/\.\w+$/.test(pathname)
) {
return next();
}
try {
const response = await next();
if (response.status === 404) {
const index = await context.env.ASSETS.fetch(
new URL("/index.html", request.url)
);
if (index.ok) return index;
}
return response;
} catch {
const index = await context.env.ASSETS.fetch(
new URL("/index.html", request.url)
);
if (index.ok) return index;
return new Response("Not Found", { status: 404 });
}
}

View file

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import './.next/types/routes.d.ts';
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View file

@ -1,3 +1,4 @@
export const runtime = "edge";
import type { Metadata } from 'next';
import { SECTIONS } from '../sections';

View file

@ -33,7 +33,6 @@ export const metadata: Metadata = {
},
],
},
twitter: undefined,
robots: {
index: true,
follow: true,

View file

@ -1,3 +1,4 @@
export const runtime = "edge";
import type { Metadata } from 'next';
export const metadata: Metadata = {

View file

@ -378,12 +378,64 @@ export function AuthProvider({ children }: { children: ReactNode }) {
auth.accessToken = token;
auth.lens_profileId = identity?.publicId ?? '';
}
// Farcaster — requires a pre-signed message (complex, not auto-done yet).
// Farcaster — sign and publish client-side (never send signer key to the server).
else if (protocol === 'farcaster') {
return {
ok: false,
error: 'Farcaster engagement requires a client-signed message. Coming soon.',
};
const farcasterIdent = identities.find((i) => i.protocol === "farcaster");
if (!farcasterIdent) return { ok: false, error: "No Farcaster identity linked" };
let signerKeyHex = null;
if (farcasterIdent.encryptedSecret) {
try {
const { decryptSecret } = await import("@degenfeed/auth");
signerKeyHex = await decryptSecret(
{
ciphertext: farcasterIdent.encryptedSecret,
salt: farcasterIdent.salt,
iv: farcasterIdent.iv,
},
"auto-unlock-passphrase",
);
} catch {
return { ok: false, error: "Could not decrypt Farcaster signer key. Re-link your account." };
}
}
if (!signerKeyHex) {
return { ok: false, error: "Farcaster signer key not found. Sign in with a self-custody signer key." };
}
const fid = parseInt(farcasterIdent.publicId, 10);
if (isNaN(fid)) return { ok: false, error: "Invalid Farcaster FID" };
const nativeId = postId.startsWith("farcaster:")
? postId.slice("farcaster:".length)
: postId.startsWith("fc:")
? postId.slice("fc:".length)
: postId;
const targetFid = opts.target?.authorId
? parseInt(opts.target.authorId, 10)
: 0;
const castHashHex = nativeId;
if (castHashHex.length < 40) {
return { ok: false, error: "Invalid Farcaster cast hash" };
}
if (action !== "like" && action !== "unlike" && action !== "repost" && action !== "reply") {
return { ok: false, error: `${action} not supported on Farcaster` };
}
const { engageFarcaster } = await import("@degenfeed/farcaster-sdk");
const result = await engageFarcaster({
action: action,
signerKeyHex,
fid,
targetFid,
castHashHex,
content: opts.content,
});
return result;
}
// Generic fallback — send accessToken as protocol key.
else {

View file

@ -22,3 +22,4 @@ export {
verifyWalletSignature,
} from './providers/wallet';
export { forgetIdentity, lockIdentity, unlockIdentity } from './session';
export { signInWithReddit, handleRedditCallback } from "./providers/reddit";

View file

@ -0,0 +1,53 @@
/**
* Reddit OAuth provider for real engagement (voting, commenting, posting).
*
* Setup: Register a Reddit app at https://www.reddit.com/prefs/apps
* Set REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET in wrangler secrets.
*/
import type { StoredIdentity } from "@degenfeed/storage";
import { buildIdentityRecord } from "../persistence";
export async function signInWithReddit(clientId: string): Promise<StoredIdentity> {
const state = crypto.randomUUID();
localStorage.setItem("reddit_oauth_state", state);
const params = new URLSearchParams({
client_id: clientId,
response_type: "code",
state,
redirect_uri: `${window.location.origin}/auth/callback?provider=reddit`,
duration: "permanent",
scope: "identity read vote submit",
});
window.location.href = `https://www.reddit.com/api/v1/authorize?${params.toString()}`;
throw new Error("Redirecting...");
}
export async function handleRedditCallback(code: string, state: string): Promise<StoredIdentity> {
const savedState = localStorage.getItem("reddit_oauth_state");
localStorage.removeItem("reddit_oauth_state");
if (!savedState || state !== savedState) throw new Error("OAuth state mismatch");
const res = await fetch("/api/auth/reddit/callback", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "include",
body: JSON.stringify({ code }),
});
if (!res.ok) throw new Error("Reddit auth failed");
const data = await res.json();
const record = await buildIdentityRecord({
protocol: "reddit",
publicId: data.id,
handle: `u/${data.name}`,
displayName: data.name,
avatarUrl: data.icon_img,
secret: data.accessToken,
passphrase: "auto-unlock",
keys: { accessToken: data.accessToken, refreshToken: data.refreshToken ?? "" },
});
const { saveIdentity } = await import("@degenfeed/storage");
await saveIdentity(record);
return record;
}

View file

@ -15,7 +15,9 @@
},
"dependencies": {
"@degenfeed/types": "workspace:*",
"@degenfeed/feed-core": "workspace:*"
"@degenfeed/feed-core": "workspace:*",
"@noble/curves": "^1.6.0",
"@noble/hashes": "^1.5.0"
},
"devDependencies": {
"@biomejs/biome": "^2.5.2",

View file

@ -0,0 +1,235 @@
import { blake3 } from "@noble/hashes/blake3";
import { ed25519 } from "@noble/curves/ed25519";
const FARCASTER_NETWORK_MAINNET = 1;
const MESSAGE_TYPE_CAST_ADD = 1;
const MESSAGE_TYPE_REACTION_ADD = 6;
const MESSAGE_TYPE_REACTION_REMOVE = 7;
const REACTION_TYPE_LIKE = 1;
const REACTION_TYPE_RECAST = 2;
const HASH_SCHEME_BLAKE3 = 1;
function varintLen(value: number): number {
if (value < 128) return 1;
if (value < 16384) return 2;
if (value < 2097152) return 3;
if (value < 268435456) return 4;
if (value < 34359738368) return 5;
return 6;
}
function encodeVarint(value: number): Uint8Array {
const out: number[] = [];
let v = value >>> 0;
while (v > 127) {
out.push((v & 0x7f) | 0x80);
v >>>= 7;
}
out.push(v & 0x7f);
return new Uint8Array(out);
}
function encodeField(wireType: number, fieldNumber: number, payload: Uint8Array): Uint8Array {
const key = encodeVarint((fieldNumber << 3) | wireType);
const out = new Uint8Array(key.length + payload.length);
out.set(key);
out.set(payload, key.length);
return out;
}
function encodeLengthDelimited(fieldNumber: number, payload: Uint8Array): Uint8Array {
const len = encodeVarint(payload.length);
const field = encodeField(2, fieldNumber, new Uint8Array([...len, ...payload]));
return field;
}
function encodeVarintField(fieldNumber: number, value: number): Uint8Array {
return encodeField(0, fieldNumber, encodeVarint(value));
}
function hexToBytes(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
}
return bytes;
}
function concatBytes(...arrays: Uint8Array[]): Uint8Array {
const total = arrays.reduce((s, a) => s + a.length, 0);
const out = new Uint8Array(total);
let offset = 0;
for (const a of arrays) {
out.set(a, offset);
offset += a.length;
}
return out;
}
function encodeCastId(fid: number, hashHex: string): Uint8Array {
const hashBytes = hexToBytes(hashHex);
if (hashBytes.length !== 20) {
throw new Error(`Cast hash must be 20 bytes (40 hex chars), got ${hashBytes.length} bytes`);
}
const inner = concatBytes(encodeVarintField(1, fid), encodeLengthDelimited(2, hashBytes));
return inner;
}
function encodeEmbedCastId(fid: number, hashHex: string): Uint8Array {
const castId = encodeCastId(fid, hashHex);
return encodeLengthDelimited(2, castId);
}
function encodeReactionBody(
reactionType: number,
targetFid: number,
targetHashHex: string
): Uint8Array {
const inner = concatBytes(
encodeVarintField(1, reactionType),
encodeLengthDelimited(2, encodeCastId(targetFid, targetHashHex))
);
return inner;
}
function encodeCastAddBody(opts: {
text?: string;
parentFid?: number;
parentHashHex?: string;
embedCastFid?: number;
embedCastHashHex?: string;
}): Uint8Array {
const parts: Uint8Array[] = [];
const encoder = new TextEncoder();
if (opts.text) {
parts.push(encodeLengthDelimited(1, encoder.encode(opts.text)));
}
if (opts.embedCastFid !== undefined && opts.embedCastHashHex) {
parts.push(encodeLengthDelimited(2, encodeEmbedCastId(opts.embedCastFid, opts.embedCastHashHex)));
}
if (opts.parentFid !== undefined && opts.parentHashHex) {
parts.push(encodeLengthDelimited(5, encodeCastId(opts.parentFid, opts.parentHashHex)));
}
return concatBytes(...parts);
}
function encodeMessageData(opts: {
type: number;
fid: number;
body: Uint8Array;
bodyField: number;
timestamp?: number;
}): Uint8Array {
const ts = opts.timestamp ?? Math.floor(Date.now() / 1000);
const parts = [
encodeVarintField(1, opts.type),
encodeVarintField(2, opts.fid),
encodeVarintField(3, ts),
encodeVarintField(4, FARCASTER_NETWORK_MAINNET),
encodeLengthDelimited(opts.bodyField, opts.body),
];
return concatBytes(...parts);
}
export interface FarcasterEngageResult {
ok: boolean;
error?: string;
}
export interface FarcasterEngageOpts {
action: "like" | "unlike" | "repost" | "reply";
signerKeyHex: string;
fid: number;
targetFid: number;
castHashHex: string;
content?: string;
}
export async function engageFarcaster(opts: FarcasterEngageOpts): Promise<FarcasterEngageResult> {
try {
const signerBytes = hexToBytes(opts.signerKeyHex);
if (signerBytes.length !== 32) {
return { ok: false, error: "Invalid signer key (must be 32 bytes / 64 hex chars)" };
}
const pubKey = ed25519.getPublicKey(signerBytes);
let messageType: number;
let bodyField: number;
let body: Uint8Array;
if (opts.action === "like") {
messageType = MESSAGE_TYPE_REACTION_ADD;
bodyField = 7;
body = encodeReactionBody(REACTION_TYPE_LIKE, opts.targetFid, opts.castHashHex);
} else if (opts.action === "unlike") {
messageType = MESSAGE_TYPE_REACTION_REMOVE;
bodyField = 7;
body = encodeReactionBody(REACTION_TYPE_LIKE, opts.targetFid, opts.castHashHex);
} else if (opts.action === "repost") {
messageType = MESSAGE_TYPE_CAST_ADD;
bodyField = 5;
body = encodeCastAddBody({
embedCastFid: opts.targetFid,
embedCastHashHex: opts.castHashHex,
});
} else if (opts.action === "reply") {
messageType = MESSAGE_TYPE_CAST_ADD;
bodyField = 5;
body = encodeCastAddBody({
text: opts.content ?? "",
parentFid: opts.targetFid,
parentHashHex: opts.castHashHex,
});
} else {
return { ok: false, error: `Unknown action: ${opts.action}` };
}
const messageData = encodeMessageData({
type: messageType,
fid: opts.fid,
body,
bodyField,
});
const hash = blake3(messageData, { dkLen: 20 });
const signature = ed25519.sign(hash, signerBytes);
const message = concatBytes(
encodeLengthDelimited(1, messageData),
encodeLengthDelimited(2, hash),
encodeVarintField(3, HASH_SCHEME_BLAKE3),
encodeLengthDelimited(4, signature),
encodeLengthDelimited(5, pubKey),
);
await submitToHub(message);
return { ok: true };
} catch (err) {
const message = err instanceof Error ? err.message : "Farcaster engagement failed";
return { ok: false, error: message };
}
}
async function submitToHub(signedMessage: Uint8Array): Promise<void> {
const hubUrl = "https://hub.farcaster.standardcrypto.vc:2281";
const res = await fetch(`${hubUrl}/v1/submitMessage`, {
method: "PUT",
headers: {
"content-type": "application/octet-stream",
"user-agent": "DegenFeed/0.1 (+https://degenfeed.xyz)",
},
body: signedMessage.buffer as ArrayBuffer,
});
if (!res.ok) {
const errText = await res.text().catch(() => "");
throw new Error(`Farcaster Hub rejected: ${res.status} ${errText.slice(0, 200)}`);
}
}

View file

@ -1,5 +1,6 @@
import type { Protocol, UnifiedIdentity } from '@degenfeed/types';
export { engageFarcaster } from "./engage";
export type { Protocol, UnifiedIdentity };
export const DEFAULT_HUB_URL = 'https://hub.farcaster.standardcrypto.vc:2281';

View file

@ -419,6 +419,30 @@ function normalizeStripped(text: string): string {
.trim();
}
function wordSet(text: string): Set<string> {
return new Set(normalizeStripped(text).split(' ').filter((w) => w.length > 2));
}
function jaccardSimilarity(a: Set<string>, b: Set<string>): number {
if (a.size === 0 || b.size === 0) return 0;
let intersection = 0;
const smaller = a.size < b.size ? a : b;
const larger = a.size < b.size ? b : a;
for (const w of smaller) {
if (larger.has(w)) intersection++;
}
return intersection / (a.size + b.size - intersection);
}
function extractUrls(post: UnifiedPost): string[] {
const textUrls = (post.text.match(/https?:\/\/[^\s<>"']+/gi) ?? [])
.map((u) => u.replace(/\/$/, '').toLowerCase());
const embedUrls = post.embeds
.filter((e) => e.kind === 'link' || e.kind === 'image' || e.kind === 'video')
.map((e) => e.url.replace(/\/$/, '').toLowerCase());
return [...new Set([...textUrls, ...embedUrls])];
}
function primaryUrl(post: UnifiedPost): string | null {
const urls = post.text.match(URL_RE);
if (urls && urls.length > 0) return urls[0].toLowerCase();
@ -481,6 +505,9 @@ export function contentFingerprint(post: UnifiedPost): string {
return link ? `link:${link.url}` : '';
}
const JACCARD_THRESHOLD = 0.7;
const MIN_TEXT_LENGTH = 20;
export function deduplicatePosts(posts: UnifiedPost[]): UnifiedPost[] {
const seen = {
id: new Set<string>(),
@ -494,6 +521,7 @@ export function deduplicatePosts(posts: UnifiedPost[]): UnifiedPost[] {
ticker: new Set<string>(),
embed: new Set<string>(),
};
const candidates: { words: Set<string>; index: number }[] = [];
const out: UnifiedPost[] = [];
for (const p of posts) {
@ -529,6 +557,22 @@ export function deduplicatePosts(posts: UnifiedPost[]): UnifiedPost[] {
const eid = embedFingerprint(p);
if (eid && seen.embed.has(eid)) continue;
const ws = wordSet(p.text);
let matchedProtocol: Protocol | undefined;
if (ws.size >= 3 && p.text.length > MIN_TEXT_LENGTH) {
for (const cand of candidates) {
const sim = jaccardSimilarity(ws, cand.words);
if (sim >= JACCARD_THRESHOLD) {
matchedProtocol = out[cand.index].protocol;
const cross = out[cand.index].crossPostedTo ?? [];
if (!cross.includes(p.protocol)) cross.push(p.protocol);
out[cand.index].crossPostedTo = cross;
break;
}
}
}
if (matchedProtocol) continue;
seen.id.add(p.id);
if (fp) seen.fingerprint.add(fp);
if (norm.length > 15) seen.normalized.add(norm);
@ -538,9 +582,10 @@ export function deduplicatePosts(posts: UnifiedPost[]): UnifiedPost[] {
if (qid) seen.quoted.add(qid);
if (ts.length > 3) seen.ticker.add(ts);
if (eid) seen.embed.add(eid);
candidates.push({ words: ws, index: out.length });
out.push(p);
}
return out;
}

View file

@ -35,3 +35,4 @@ export {
withRetry,
withTimeout,
} from './utils';
export { PonderProvider } from "./providers/ponder";

View file

@ -0,0 +1,146 @@
import { detectNiches, buildUnifiedPost } from "@degenfeed/feed-core";
import type { Protocol, UnifiedIdentity, UnifiedPost } from "@degenfeed/types";
import type { FeedProvider, FeedResult, FetchOptions, HealthResult } from "../types";
import { DEFAULT_TIMEOUT_MS, fetchJson, withTimeout } from "../utils";
interface PonderTransfer {
id: string;
fromAddress: string;
toAddress: string;
value: string;
txHash: string;
blockNumber: number;
timestamp: number;
chainId: number;
tokenAddress: string;
}
const CHAIN_NAMES: Record<number, string> = {
1: "Ethereum",
56: "BSC",
137: "Polygon",
42161: "Arbitrum",
8453: "Base",
};
const CHAIN_EXPLORERS: Record<number, string> = {
1: "https://etherscan.io/tx",
56: "https://bscscan.com/tx",
137: "https://polygonscan.com/tx",
42161: "https://arbiscan.io/tx",
8453: "https://basescan.org/tx",
};
const PONDER_ENDPOINTS: { port: number; chainId: number }[] = [
{ port: 42069, chainId: 1 },
{ port: 42070, chainId: 56 },
{ port: 42071, chainId: 137 },
{ port: 42072, chainId: 42161 },
];
function transferToUnifiedPost(t: PonderTransfer): UnifiedPost {
const chainName = CHAIN_NAMES[t.chainId] ?? `Chain ${t.chainId}`;
const explorer = CHAIN_EXPLORERS[t.chainId];
const valueNum = Number(t.value) / 1e18;
const shortValue = valueNum > 1000000
? `$${(valueNum / 1e6).toFixed(2)}M`
: valueNum > 1000
? `$${(valueNum / 1e3).toFixed(1)}K`
: `$${valueNum.toFixed(2)}`;
const fromShort = `${t.fromAddress.slice(0, 6)}...${t.fromAddress.slice(-4)}`;
const toShort = `${t.toAddress.slice(0, 6)}...${t.toAddress.slice(-4)}`;
const text = `${shortValue} USDC transferred on ${chainName}\nFrom: ${fromShort}\nTo: ${toShort}`;
const txUrl = explorer ? `${explorer}/${t.txHash}` : undefined;
const identity: UnifiedIdentity = {
id: `ponder:${t.chainId}`,
primary: { protocol: "ponder" as Protocol, id: String(t.chainId) },
handles: { nostr: null, farcaster: null, lens: null, bluesky: null, threads: null, rss: null, mastodon: null },
displayName: `${chainName} On-Chain`,
bio: `Token transfers on ${chainName}`,
avatarUrl: "",
verified: [],
followerCount: 0,
followingCount: 0,
links: [],
keys: {},
};
const embeds = txUrl ? [{ kind: "link" as const, url: txUrl }] : [];
return buildUnifiedPost({
protocol: "ponder" as Protocol,
nativeId: t.id,
author: identity,
text,
embeds,
createdAt: t.timestamp * 1000,
metrics: { likes: 0, reposts: 0, replies: 0, quotes: 0 },
raw: t,
});
}
export class PonderProvider implements FeedProvider {
readonly protocol: Protocol = "ponder" as Protocol;
private readonly baseUrl: string;
constructor(baseUrl = "http://127.0.0.1") {
this.baseUrl = baseUrl;
}
async fetchFeed(opts: FetchOptions = {}): Promise<FeedResult> {
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const posts: UnifiedPost[] = [];
for (const ep of PONDER_ENDPOINTS) {
try {
const url = `${this.baseUrl}:${ep.port}/graphql`;
const query = {
query: `{ tokenTransfers(limit: 25, orderBy: "timestamp", orderDirection: "desc") { items { id fromAddress toAddress value txHash blockNumber timestamp chainId tokenAddress } } }`,
};
const data = await withTimeout(
fetchJson<{ data?: { tokenTransfers?: { items?: PonderTransfer[] } } }>(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(query),
}),
timeoutMs,
`ponder:${ep.chainId}`,
);
const items = data?.data?.tokenTransfers?.items ?? [];
for (const t of items) {
posts.push(transferToUnifiedPost(t));
}
} catch {
// Ponder instance not ready yet — skip
}
}
for (const p of posts) {
p.niches = detectNiches(p.text);
}
return { posts };
}
async healthCheck(opts: FetchOptions = {}): Promise<HealthResult> {
const timeoutMs = opts.timeoutMs ?? 5000;
const start = Date.now();
try {
const url = `${this.baseUrl}:42070/graphql`;
await withTimeout(
fetchJson(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ query: "{ _meta { status { bsc { block ready } } } }" }),
}),
timeoutMs,
"ponder-health",
);
return { healthy: true, latencyMs: Date.now() - start };
} catch {
return { healthy: false, latencyMs: Date.now() - start };
}
}
}

View file

@ -0,0 +1,45 @@
import type { UnifiedPost } from "@degenfeed/types";
const REDLIB_URL = typeof process !== "undefined"
? (process.env?.REDLIB_URL ?? "http://127.0.0.1:8281")
: "http://127.0.0.1:8281";
export interface RedditEngageResult {
ok: boolean;
error?: string;
}
export async function engageReddit(opts: {
action: "like" | "unlike" | "reply" | "repost";
postId: string;
subreddit: string;
commentId?: string;
content?: string;
token?: string;
}): Promise<RedditEngageResult> {
try {
const effectiveAction = opts.action === "like" ? "upvote" : opts.action === "unlike" ? "downvote" : opts.action;
if (effectiveAction === "reply") {
return { ok: false, error: "Reddit comment posting requires OAuth. Coming soon." };
}
if (effectiveAction === "repost") {
return { ok: false, error: "Reddit crosspost requires OAuth. Coming soon." };
}
// Voting via Redlib: POST /r/{sub}/api/vote with id=postId&dir=1 (up) or -1 (down)
const formBody = new URLSearchParams({
id: `t3_${opts.postId}`,
dir: effectiveAction === "upvote" ? "1" : "-1",
});
const res = await fetch(`${REDLIB_URL}/r/${opts.subreddit}/api/vote`, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: formBody.toString(),
});
if (!res.ok) {
return { ok: false, error: `Redlib vote failed: ${res.status}` };
}
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : "Reddit engagement failed" };
}
}

View file

@ -51,16 +51,20 @@ export const DEFAULT_SUBREDDITS = [
'cryptomining',
];
// Open-source Reddit API frontends (libreddit, teddit, etc.)
// These proxy Reddit without auth and return JSON.
const REDDIT_FRONTENDS = [
// Self-hosted Redlib on Talos (primary). Falls back to community instances.
const SELF_HOSTED_REDLIB = typeof process !== "undefined"
? (process.env?.REDLIB_URL ?? "http://127.0.0.1:8281")
: "http://127.0.0.1:8281";
const COMMUNITY_REDDIT_FRONTENDS = [
'https://libreddit.kavin.rocks',
'https://libreddit.privacydev.net',
'https://lr.vern.cc',
'https://teddit.net',
'https://red.ngn.tf',
];
const REDDIT_FRONTENDS = [SELF_HOSTED_REDLIB, ...COMMUNITY_REDDIT_FRONTENDS];
// Reddit's own RSS feeds — these are public, no auth needed.
// We use www.reddit.com (old.reddit.com is deprecated).
const REDDIT_RSS_BASE = 'https://www.reddit.com';

View file

@ -0,0 +1,64 @@
/**
* RSS-Bridge Configuration Crypto Content Sources
*
* Deployed at http://127.0.0.1:8282 on Talos.
*
* Each bridge URL can be added to the RSS provider's feed list.
* Format: http://127.0.0.1:8282/?action=display&bridge={BridgeName}&format=Atom&{params}
*
* === Working Bridges (tested) ===
*
* FilterBridge filter any RSS feed by keyword:
* ?action=display&bridge=FilterBridge&format=Atom&url={encoded_rss_url}&filter={keyword}
*
* RedditBridge subreddit feeds (alternative to Redlib):
* ?action=display&bridge=RedditBridge&format=Atom&r={subreddit}&sort=hot
*
* CssSelectorBridge scrape any website with CSS selectors:
* ?action=display&bridge=CssSelectorBridge&format=Atom&url={encoded_url}&selector={css}
*
* === Bridges to configure ===
*
* Farcaster add Farcaster feed:
* ?action=display&bridge=FarcasterBridge&format=Atom&channel={channel_url}
*
* HackerNews top stories:
* ?action=display&bridge=HackerNewsBridge&format=Atom
*
* YouTube channel videos (alternative to native RSS):
* ?action=display&bridge=YoutubeBridge&format=Atom&c={channel_id}
*
* Twitch streamer clips:
* ?action=display&bridge=TwitchBridge&format=Atom&channel={channel_name}
*
* === Curated Bridge Feed URLs (add to DEFAULT_CRYPTO_FEEDS in rss.ts) ===
*/
export const RSS_BRIDGE_FEEDS = [
// HackerNews top stories (tech/startup news)
"http://127.0.0.1:8282/?action=display&bridge=HackerNewsBridge&format=Atom",
// GitHub trending repos (daily)
"http://127.0.0.1:8282/?action=display&bridge=GithubTrendingBridge&format=Atom",
// Lobsters (programming community, high signal)
"http://127.0.0.1:8282/?action=display&bridge=LobstersBridge&format=Atom",
// Google News — crypto topic
"http://127.0.0.1:8282/?action=display&bridge=GoogleNewsBridge&format=Atom&q=cryptocurrency&hl=en",
// YouTube: Bankless
"http://127.0.0.1:8282/?action=display&bridge=YoutubeBridge&format=Atom&c=UCAl9Ld79qaZxp9JzEOwd3aA",
// YouTube: Coin Bureau
"http://127.0.0.1:8282/?action=display&bridge=YoutubeBridge&format=Atom&c=UCqK_GSMbpiV8spgD3ZGloSw",
// Filter: Cointelegraph → bitcoin only
"http://127.0.0.1:8282/?action=display&bridge=FilterBridge&format=Atom&url=https%3A%2F%2Fcointelegraph.com%2Frss&filter=bitcoin",
// Filter: Decrypt → ethereum only
"http://127.0.0.1:8282/?action=display&bridge=FilterBridge&format=Atom&url=https%3A%2F%2Fdecrypt.co%2Ffeed&filter=ethereum",
// Filter: Coindesk → regulation only
"http://127.0.0.1:8282/?action=display&bridge=FilterBridge&format=Atom&url=https%3A%2F%2Fwww.coindesk.com%2Farc%2Foutboundfeeds%2Frss%2F&filter=regulation",
];

View file

@ -622,7 +622,7 @@ export class RssProvider implements FeedProvider {
private readonly feeds: string[];
constructor(options: RssProviderOptions = {}) {
this.feeds = options.feeds ?? DEFAULT_CRYPTO_FEEDS;
this.feeds = [...new Set(options.feeds ?? DEFAULT_CRYPTO_FEEDS)];
}
async fetchFeed(opts: FetchOptions = {}): Promise<FeedResult> {

View file

@ -19,7 +19,9 @@ import type { FeedProvider, FeedResult, FetchOptions, HealthResult } from '../ty
import { DEFAULT_TIMEOUT_MS, fetchText, withTimeout } from '../utils';
export const THREADS_WEB_URL = 'https://www.threads.net';
export const FLARESOLVERR_URL = 'http://100.104.130.92:8191/v1';
export const FLARESOLVERR_URL = typeof process !== "undefined" && process.env?.FLARESOLVERR_URL
? process.env.FLARESOLVERR_URL
: ""; // Set FLARESOLVERR_URL env var or leave empty to disable
export const CURATED_THREADS_HANDLES = [
'zuck',

View file

@ -51,6 +51,7 @@ export const PLATFORM_TIER: Record<Protocol, number> = {
threads: 0.7,
reddit: 0.8,
rss: 0.8,
ponder: 0.8,
ethereum: 0.6,
solana: 0.6,
};

View file

@ -14,6 +14,7 @@ export type Protocol =
| 'mastodon'
| 'reddit'
| 'ethereum'
| 'ponder'
| 'solana';
export interface UnifiedIdentity {
@ -117,6 +118,8 @@ export interface UnifiedPost {
/** Niche tags derived from content (crypto, ai, oss, security, art, music) */
niches: string[];
/** Optional ranking breakdown for transparency / hover signals. */
/** Protocols this post was also found on (cross-post dedup). */
crossPostedTo?: Protocol[];
score?: PostScore;
/** Optional paywall requirement for paid content */
paywall?: { amount: string; recipient: string };

12
pnpm-lock.yaml generated
View file

@ -213,6 +213,12 @@ importers:
'@degenfeed/types':
specifier: workspace:*
version: link:../types
'@noble/curves':
specifier: ^1.6.0
version: 1.9.7
'@noble/hashes':
specifier: ^1.5.0
version: 1.8.0
devDependencies:
'@biomejs/biome':
specifier: ^2.5.2
@ -7951,7 +7957,7 @@ snapshots:
'@scure/bip32@1.7.0':
dependencies:
'@noble/curves': 1.9.1
'@noble/curves': 1.9.7
'@noble/hashes': 1.8.0
'@scure/base': 1.2.6
@ -12799,8 +12805,8 @@ snapshots:
ox@0.6.7(typescript@5.9.3)(zod@3.22.4):
dependencies:
'@adraffy/ens-normalize': 1.11.1
'@noble/curves': 1.8.1
'@noble/hashes': 1.7.1
'@noble/curves': 1.9.7
'@noble/hashes': 1.8.0
'@scure/bip32': 1.6.2
'@scure/bip39': 1.5.4
abitype: 1.0.8(typescript@5.9.3)(zod@3.22.4)

View file

@ -21,6 +21,7 @@ import { handleCron } from './routes/cron';
import { handleNewsletterFeed, handleTopFeed } from './routes/curated';
import { handleDiscover } from './routes/discover';
import { handleEngage } from './routes/engage';
import { handlePushSend, handlePushSubscribe } from "./routes/push";
import {
handleCategoryFeed,
handleFeedHealth,
@ -109,6 +110,8 @@ export interface Env {
SENTRY_DSN?: string;
/** Optional Postmark server token for transactional email delivery */
POSTMARK_SERVER_TOKEN?: string;
ALLOW_DEV_OAUTH?: string;
ADMIN_API_KEY?: string;
}
// ─── Shared constants ────────────────────────────────────────────────
@ -246,7 +249,7 @@ const CSRF_EXEMPT_PATHS = [
// /api/auth/gotosocial/callback is now protected by a signed state nonce
// (see routes/auth.ts) AND a normal origin check, so it's no longer exempt.
'/api/tip/frame/',
'/api/bridge/rss-to-nostr',
// '/api/bridge/rss-to-nostr' is NO LONGER exempt — requires CSRF check
'/api/blink/tip',
'/api/x402/verify',
];
@ -456,10 +459,22 @@ export default {
response = await handleNotify(request, env);
} else if (path === '/api/discover') {
response = await handleDiscover(request, env);
} else if (path === "/api/push/subscribe") {
response = await handlePushSubscribe(request, env);
} else if (path === "/api/push/send") {
response = await handlePushSend(request, env);
} else if (path === '/api/newsletter/subscribe') {
response = await handleNewsletterSubscribe(request, env);
} else if (path === "/api/push/subscribe") {
response = await handlePushSubscribe(request, env);
} else if (path === "/api/push/send") {
response = await handlePushSend(request, env);
} else if (path === '/api/newsletter/status') {
response = await handleNewsletterStatus(request, env);
} else if (path === "/api/push/subscribe") {
response = await handlePushSubscribe(request, env);
} else if (path === "/api/push/send") {
response = await handlePushSend(request, env);
} else if (path === '/api/newsletter/digest' || path.startsWith('/api/newsletter/digest')) {
response = await handleNewsletterDigest(request, env);
} else if (path === '/api/search/identity') {

View file

@ -41,8 +41,16 @@ async function consumeState(env: Env, state: string): Promise<boolean> {
*/
export async function handleGtSAuthorize(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const redirect = url.searchParams.get('redirect') || getGtsConfig(env).redirectUri;
const devMode = url.searchParams.get('dev') === 'true';
const rawRedirect = url.searchParams.get('redirect');
const allowedRedirects = [
getGtsConfig(env).redirectUri,
'https://degenfeed.xyz/auth/callback',
'https://app.degenfeed.xyz/auth/callback',
];
const redirect = rawRedirect && allowedRedirects.some((r) => rawRedirect.startsWith(r))
? rawRedirect
: getGtsConfig(env).redirectUri;
const devMode = env.ALLOW_DEV_OAUTH === 'true' && url.searchParams.get('dev') === 'true';
const state = generateState();
await storeState(env, state);

View file

@ -5,6 +5,7 @@ import {
isSpamPost,
} from '@degenfeed/feed-core';
import {
PonderProvider,
BlueskyProvider,
FarcasterProvider,
type FeedProvider,
@ -38,6 +39,7 @@ export const DEFAULT_PROTOCOLS: Protocol[] = [
'bluesky',
'mastodon',
'threads',
'ponder',
];
const DEFAULT_TIMEOUT_MS = 6000;
@ -60,6 +62,8 @@ export function getProvider(protocol: Protocol, env: Env): FeedProvider {
return new ThreadsProvider();
case 'reddit':
return new RedditProvider();
case 'ponder':
return new PonderProvider();
case 'rss':
return new RssWithFallbackProvider(env);
default:

View file

@ -452,12 +452,16 @@ export async function handleNewsletterDigest(request: Request, env: Env): Promis
return noCacheJsonResponse(response);
}
export async function handleNewsletterStatus(_request: Request, env: Env): Promise<Response> {
export async function handleNewsletterStatus(request: Request, env: Env): Promise<Response> {
const auth = request.headers.get('authorization') ?? '';
if (auth !== `Bearer ${env.ADMIN_API_KEY ?? ''}`) {
return jsonResponse({ error: 'Unauthorized' }, 401);
}
try {
const idx = (await env.CACHE?.get('newsletter:subscribers')) ?? '';
const emails = idx.split('\n').filter(Boolean);
return jsonResponse({ count: emails.length, sample: emails.slice(0, 5) });
return jsonResponse({ count: emails.length });
} catch {
return jsonResponse({ count: 0, sample: [], warning: 'KV unavailable' });
return jsonResponse({ count: 0, warning: 'KV unavailable' });
}
}

View file

@ -17,8 +17,16 @@ import {
// ─── RSS-to-Nostr bridge ─────────────────────────────────────────────
// Fetches an RSS feed, converts each item to a Nostr text event,
// signs with the provided key, and publishes to Nostr relays.
//
// SECURITY: This endpoint accepts a raw nsec in the request body.
// We validate the nsec format and apply strict rate limiting. In a
// future iteration, we should replace this with a pre-signed event
// model where the client signs locally and only submits the signed
// event to the server. The raw nsec should never traverse the wire.
export async function handleRssToNostrBridge(request: Request, _env: Env): Promise<Response> {
const MAX_BRIDGE_PER_HOUR = 3;
export async function handleRssToNostrBridge(request: Request, env: Env): Promise<Response> {
try {
const body = (await request.json()) as Record<string, string>;
const feedUrl = body.feedUrl;
@ -26,6 +34,9 @@ export async function handleRssToNostrBridge(request: Request, _env: Env): Promi
if (!feedUrl || !nsec) {
return jsonResponse({ error: 'feedUrl and nsec are required' }, 400);
}
if (!/^nsec1[023456789acdefghjklmnpqrstuvwxyz]{58}$/.test(nsec.trim())) {
return jsonResponse({ error: 'Invalid nsec format' }, 400);
}
// 1. Fetch and parse RSS
const { GenericRssSource, feedToUnifiedPosts } = await import('@degenfeed/rss-sdk');

View file

@ -0,0 +1,77 @@
/**
* PWA Push Notification Worker
*
* Routes:
* POST /api/push/subscribe store a push subscription in KV
* POST /api/push/send send a push to all subscribers (admin)
*/
import { jsonResponse, type Env } from "../index";
interface PushSubscription {
endpoint: string;
keys: { p256dh: string; auth: string };
subscribedAt: number;
}
export async function handlePushSubscribe(request: Request, env: Env): Promise<Response> {
try {
const body = await request.json() as PushSubscription;
if (!body.endpoint || !body.keys?.p256dh || !body.keys?.auth) {
return jsonResponse({ error: "Invalid subscription" }, 400);
}
const sub: PushSubscription = { ...body, subscribedAt: Date.now() };
await env.CACHE?.put(`push:sub:${body.endpoint}`, JSON.stringify(sub), { expirationTtl: 30 * 86400 });
return jsonResponse({ ok: true });
} catch {
return jsonResponse({ error: "Invalid request" }, 400);
}
}
export async function handlePushSend(request: Request, env: Env): Promise<Response> {
const auth = request.headers.get("authorization") ?? "";
if (auth !== `Bearer ${env.ADMIN_API_KEY ?? ""}`) {
return jsonResponse({ error: "Unauthorized" }, 401);
}
try {
const { title, body, icon, url } = await request.json() as {
title: string; body: string; icon?: string; url?: string;
};
if (!title || !body) return jsonResponse({ error: "title and body required" }, 400);
const subs = await listKVKeys(env.CACHE, "push:sub:");
const payload = JSON.stringify({ title, body, icon: icon ?? "/icon-192.png", data: { url } });
const results: { endpoint: string; ok: boolean; error?: string }[] = [];
for (const key of subs) {
const raw = await env.CACHE?.get(key);
if (!raw) continue;
const sub = JSON.parse(raw) as PushSubscription;
try {
const res = await fetch(sub.endpoint, {
method: "POST",
headers: { "content-type": "application/json" },
body: payload,
});
results.push({ endpoint: sub.endpoint, ok: res.ok });
} catch (e) {
results.push({ endpoint: sub.endpoint, ok: false, error: String(e) });
}
}
return jsonResponse({ sent: results.length, results });
} catch (e) {
return jsonResponse({ error: String(e) }, 500);
}
}
async function listKVKeys(kv: KVNamespace | undefined, prefix: string): Promise<string[]> {
if (!kv) return [];
const keys: string[] = [];
let cursor: string | undefined;
do {
const result = await kv.list({ prefix, cursor });
keys.push(...result.keys.map((k) => k.name));
cursor = result.list_complete ? undefined : result.cursor;
} while (cursor);
return keys;
}

View file

@ -18,10 +18,21 @@ function jsonResponse(data: unknown, status = 200): Response {
});
}
function decodeHex(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
}
return bytes;
}
async function importKey(secret: string): Promise<CryptoKey> {
const keyBytes = /^[0-9a-fA-F]+$/.test(secret) && secret.length >= 64
? decodeHex(secret)
: new TextEncoder().encode(secret);
return crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
keyBytes as Uint8Array<ArrayBuffer>,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign', 'verify'],