feat: Reddit OAuth, PWA push, RSS-Bridge feeds, PonderProvider
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

- Reddit OAuth sign-in provider (identity, vote, submit scopes)
- Push notification worker: POST /api/push/subscribe + /api/push/send (KV-backed)
- RSS-Bridge curated feed config: HackerNews, GitHub Trending, Lobsters, filtered crypto
- PonderProvider wired into feed pipeline (all 4 chains + platform weights)
This commit is contained in:
Crypto Rug Munch 2026-07-08 18:40:31 +02:00
parent 27baad4281
commit 82efae2497
5 changed files with 208 additions and 0 deletions

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

@ -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

@ -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,
@ -458,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

@ -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;
}