feat: Reddit OAuth, PWA push, RSS-Bridge feeds, PonderProvider
- 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:
parent
27baad4281
commit
82efae2497
5 changed files with 208 additions and 0 deletions
|
|
@ -22,3 +22,4 @@ export {
|
|||
verifyWalletSignature,
|
||||
} from './providers/wallet';
|
||||
export { forgetIdentity, lockIdentity, unlockIdentity } from './session';
|
||||
export { signInWithReddit, handleRedditCallback } from "./providers/reddit";
|
||||
|
|
|
|||
53
packages/auth/src/providers/reddit.ts
Normal file
53
packages/auth/src/providers/reddit.ts
Normal 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;
|
||||
}
|
||||
64
packages/feed-providers/src/providers/rss-bridge-feeds.ts
Normal file
64
packages/feed-providers/src/providers/rss-bridge-feeds.ts
Normal 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",
|
||||
];
|
||||
|
|
@ -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') {
|
||||
|
|
|
|||
77
workers/api/src/routes/push.ts
Normal file
77
workers/api/src/routes/push.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue