feat(sprint-c): pnpm pinning + session refresh + notify + GDPR + legal

- .github/renovate.json: auto-merge patch updates, weekly Monday schedule,
  web3 deps require manual minor review, dev-tooling auto-merges.
- workers/api/src/session.ts: refreshSessionCookie() + applySessionRefresh().
  Every authenticated request now re-issues __Host-dfid with a fresh 7-day
  TTL for sliding expiration. No more silent login expiry.
- workers/api/src/index.ts: withSession() helper wraps requireSession +
  handler + cookie refresh. All 5 protected routes use it.
- workers/api/src/routes/notify.ts: POST /api/notify endpoint for landing
  page email subscriptions. KV-backed (SUBSCRIPTIONS). Capped at 10k.
- workers/api/src/routes/account.ts: POST /api/account/export (return all
  user data from KV) and POST /api/account/delete (wipe + clear cookie).
  GDPR portability + right-to-erasure.
- apps/web/src/app/privacy/page.tsx, terms/page.tsx: legal pages.
- apps/web/src/components/Shell.tsx: Privacy + Terms footer links.
This commit is contained in:
Crypto Rug Munch 2026-07-08 12:21:21 +07:00
parent d4d7a448cd
commit f2fcf7d9c2
9 changed files with 455 additions and 36 deletions

76
.github/renovate.json vendored Normal file
View file

@ -0,0 +1,76 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"enabledManagers": ["npm"],
"packageRules": [
{
"matchUpdateTypes": ["patch"],
"automerge": true,
"automergeType": "branch",
"platformAutomerge": true,
"description": "Auto-merge patch updates (bug fixes) after CI passes"
},
{
"matchPackageNames": [
"@solana/web3.js",
"@solana/wallet-adapter-base",
"@solana/wallet-adapter-react",
"@solana/wallet-adapter-wallets",
"@web3modal/wagmi",
"viem",
"wagmi"
],
"matchUpdateTypes": ["minor"],
"automerge": false,
"description": "Web3 deps need manual review on minor bumps"
},
{
"matchPackageNames": ["next", "react", "react-dom"],
"matchUpdateTypes": ["minor"],
"automerge": false,
"description": "Framework deps — always manual review"
},
{
"matchPackageNames": ["@biomejs/biome", "typescript", "vitest"],
"groupName": "dev-tooling",
"automerge": true,
"automergeType": "branch",
"platformAutomerge": true,
"description": "Dev tooling can auto-merge"
},
{
"matchDepTypes": ["devDependencies"],
"matchUpdateTypes": ["patch"],
"automerge": true,
"automergeType": "branch"
},
{
"matchPackageNames": [
"@degenfeed/api-worker",
"@degenfeed/auth",
"@degenfeed/bluesky-sdk",
"@degenfeed/farcaster-sdk",
"@degenfeed/feed-core",
"@degenfeed/feed-providers",
"@degenfeed/identity",
"@degenfeed/lens-sdk",
"@degenfeed/mastodon-sdk",
"@degenfeed/nostr-sdk",
"@degenfeed/ranking",
"@degenfeed/rss-sdk",
"@degenfeed/storage",
"@degenfeed/threads-sdk",
"@degenfeed/types",
"@degenfeed/ui",
"@degenfeed/utils"
],
"enabled": false,
"description": "Internal workspace packages — never bump externally"
}
],
"labels": ["dependencies"],
"prHourlyLimit": 5,
"schedule": ["before 6am on Monday"],
"timezone": "UTC",
"dependencyDashboard": true
}

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

@ -0,0 +1,75 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Privacy Policy — DegenFeed',
};
export default function PrivacyPage() {
return (
<div className="mx-auto max-w-2xl px-4 py-8 text-sm leading-relaxed text-gray-300">
<h1 className="mb-6 text-2xl font-bold text-white">Privacy Policy</h1>
<p className="mb-4 text-gray-500">Last updated: July 2026</p>
<h2 className="mb-2 text-lg font-semibold text-white">1. What We Collect</h2>
<p className="mb-4">DegenFeed is an open-source social aggregator. We store:</p>
<ul className="mb-4 list-inside list-disc space-y-1">
<li>Wallet addresses (Ethereum/Solana) you choose to sign in with.</li>
<li>
Social protocol credentials (Nostr nsec, Bluesky app passwords, etc.) encrypted in your
browser <strong>never sent to our servers</strong>.
</li>
<li>Email addresses if you subscribe to the newsletter.</li>
<li>
Preferences (hide low-quality, non-crypto toggle, favorite niches) stored in Cloudflare
KV, keyed to your wallet address.
</li>
</ul>
<h2 className="mb-2 text-lg font-semibold text-white">2. What We Do NOT Collect</h2>
<ul className="mb-4 list-inside list-disc space-y-1">
<li>Your name, phone number, or physical address.</li>
<li>IP addresses (Cloudflare may log them for DDoS protection; we do not store them).</li>
<li>Browsing history outside DegenFeed.</li>
<li>
Cookies beyond the session cookie (
<code className="rounded bg-gray-800 px-1 text-xs">__Host-dfid</code>) used for sign-in.
</li>
</ul>
<h2 className="mb-2 text-lg font-semibold text-white">3. How We Use Data</h2>
<p className="mb-4">
Your wallet address is used to personalize your feed (show posts from followed accounts,
apply your quality filter). Newsletter emails are used only to send the daily digest.
</p>
<h2 className="mb-2 text-lg font-semibold text-white">4. Your Rights</h2>
<p className="mb-4">You can export or delete all your data at any time:</p>
<ul className="mb-4 list-inside list-disc space-y-1">
<li>
Export: sign in and visit{' '}
<code className="rounded bg-gray-800 px-1 text-xs">/api/account/export</code>
</li>
<li>
Delete: sign in and POST to{' '}
<code className="rounded bg-gray-800 px-1 text-xs">/api/account/delete</code>
</li>
<li>All data is stored in Cloudflare KV. Deletion is immediate and permanent.</li>
</ul>
<h2 className="mb-2 text-lg font-semibold text-white">5. Third Parties</h2>
<p className="mb-4">
We use Cloudflare (infrastructure) and Plausible (anonymous analytics). No ad networks, no
trackers, no data brokers.
</p>
<h2 className="mb-2 text-lg font-semibold text-white">6. Contact</h2>
<p>
Questions? Email{' '}
<a href="mailto:admin@rugmunch.io" className="text-degen-400 underline">
admin@rugmunch.io
</a>
.
</p>
</div>
);
}

View file

@ -0,0 +1,56 @@
import type { Metadata } from 'next';
export const metadata: Metadata = { title: 'Terms of Service — DegenFeed' };
export default function TermsPage() {
return (
<div className="mx-auto max-w-2xl px-4 py-8 text-sm leading-relaxed text-gray-300">
<h1 className="mb-6 text-2xl font-bold text-white">Terms of Service</h1>
<p className="mb-4 text-gray-500">Last updated: July 2026</p>
<h2 className="mb-2 text-lg font-semibold text-white">1. Acceptance</h2>
<p className="mb-4">
By using DegenFeed, you agree to these terms. If you do not agree, do not use the service.
</p>
<h2 className="mb-2 text-lg font-semibold text-white">2. Eligibility</h2>
<p className="mb-4">
You must be at least 13 years old. If you are under 18, you need parental consent.
</p>
<h2 className="mb-2 text-lg font-semibold text-white">3. Your Content</h2>
<p className="mb-4">
You own the content you post. By posting, you grant DegenFeed a limited license to display
it in feeds and search results. DegenFeed does not claim ownership of your posts. DegenFeed
is an aggregator your posts live on the underlying protocols (Nostr, Farcaster, Lens,
Bluesky, Mastodon). DegenFeed does not store or archive your content beyond what is
necessary to display feeds.
</p>
<h2 className="mb-2 text-lg font-semibold text-white">4. Acceptable Use</h2>
<p className="mb-4">
See our{' '}
<a href="/acceptable-use" className="text-degen-400 underline">
Acceptable Use Policy
</a>
.
</p>
<h2 className="mb-2 text-lg font-semibold text-white">5. Disclaimer</h2>
<p className="mb-4">
DegenFeed is provided &ldquo;as is&rdquo; without warranty. We are not responsible for
content posted by third parties on connected networks. Content may include financial
opinions nothing on DegenFeed is financial advice.
</p>
<h2 className="mb-2 text-lg font-semibold text-white">6. Limitation of Liability</h2>
<p className="mb-4">
To the fullest extent permitted by law, DegenFeed and its operators shall not be liable for
any indirect, incidental, or consequential damages arising from your use of the service.
</p>
<h2 className="mb-2 text-lg font-semibold text-white">7. Changes</h2>
<p>We may update these terms. Continued use after changes constitutes acceptance.</p>
</div>
);
}

View file

@ -211,6 +211,14 @@ export function Shell({ children }: { children: React.ReactNode }) {
Profile
</a>
</div>
<div className="flex items-center gap-4 text-xs text-gray-600">
<a href="/privacy" className="transition-colors hover:text-gray-400">
Privacy
</a>
<a href="/terms" className="transition-colors hover:text-gray-400">
Terms
</a>
</div>
<p className="text-xs text-gray-600">
© {new Date().getFullYear()} DegenFeed. No account required.
</p>

View file

@ -13,6 +13,7 @@ import {
isAllowedOrigin,
parseOriginHeader,
} from './cors';
import { handleAccountDelete, handleAccountExport } from './routes/account';
import { handleGtSAuthorize, handleGtSCallback, handleGtSRegister } from './routes/auth';
import { handleBillingCheckout, handleBillingStatus, handleBillingVerify } from './routes/billing';
import { handleBlinkTip } from './routes/blink';
@ -37,6 +38,7 @@ import {
handleNewsletterSubscribe,
} from './routes/newsletter';
import { handleNotifications } from './routes/notifications';
import { handleNotify } from './routes/notify';
import { handlePostById } from './routes/post';
import { handlePreferences } from './routes/preferences';
import { handlePrices } from './routes/prices';
@ -65,7 +67,12 @@ import {
} from './routes/tip';
import { handleWalletNonce, handleWalletVerify } from './routes/wallet';
import { handleX402Paywall, handleX402Verify } from './routes/x402';
import { handleSessionSignOut, handleSessionVerify, requireSession } from './session';
import {
applySessionRefresh,
handleSessionSignOut,
handleSessionVerify,
requireSession,
} from './session';
// ─── Environment variables ───────────────────────────────────────────
@ -267,6 +274,15 @@ export default {
const path = url.pathname;
const origin = getAllowedOrigin(request, env.ALLOWED_ORIGINS);
// Helper: require session, run handler, attach refreshed cookie.
const withSession = async (handler: () => Promise<Response>): Promise<Response> => {
const session = await requireSession(request, env);
if (session instanceof Response) return session;
const resp = await handler();
if (!env.AUTH_SECRET) return resp;
return applySessionRefresh(resp, session, env.AUTH_SECRET);
};
// CORS preflight
if (request.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: corsHeaders(origin) });
@ -340,13 +356,10 @@ export default {
const category = path.replace('/api/feed/category/', '');
response = await handleCategoryFeed(request, env, category);
} else if (path === '/api/bridge/rss-to-nostr') {
const session = await requireSession(request, env);
if (session instanceof Response) {
response = session;
} else {
response = await withSession(async () => {
const { handleRssToNostrBridge } = await import('./routes/publish');
response = await handleRssToNostrBridge(request, env);
}
return handleRssToNostrBridge(request, env);
});
} else if (path === '/api/feed/rss') {
response = await handleRssFeed(request, env);
} else if (path === '/api/auth/signout') {
@ -359,20 +372,14 @@ export default {
response = await handleGtSRegister(request, env);
} else if (path === '/api/auth/session') {
response = await handleSessionVerify(request, env);
} else if (path === '/api/account/export') {
response = await handleAccountExport(request, env);
} else if (path === '/api/account/delete') {
response = await handleAccountDelete(request, env);
} else if (path === '/api/publish') {
const session = await requireSession(request, env);
if (session instanceof Response) {
response = session;
} else {
response = await handlePublish(request, env);
}
response = await withSession(() => handlePublish(request, env));
} else if (path === '/api/engage') {
const session = await requireSession(request, env);
if (session instanceof Response) {
response = session;
} else {
response = await handleEngage(request, env);
}
response = await withSession(() => handleEngage(request, env));
} else if (path === '/api/feed/top') {
response = await handleTopFeed(request, env);
} else if (path === '/api/feed/newsletters') {
@ -394,23 +401,13 @@ export default {
} else if (path === '/api/tip/resolve') {
response = await handleTipResolve(request, env);
} else if (path === '/api/tip/create') {
const session = await requireSession(request, env);
if (session instanceof Response) {
response = session;
} else {
response = await handleTipCreate(request, env);
}
response = await withSession(() => handleTipCreate(request, env));
} else if (path === '/api/tip/settings') {
const session = await requireSession(request, env);
if (session instanceof Response) {
response = session;
} else if (request.method === 'GET') {
response = await handleTipSettingsGet(request, env);
} else if (request.method === 'POST') {
response = await handleTipSettingsPost(request, env);
} else {
response = jsonResponse({ error: 'Method not allowed' }, 405);
}
response = await withSession(async () => {
if (request.method === 'GET') return handleTipSettingsGet(request, env);
if (request.method === 'POST') return handleTipSettingsPost(request, env);
return jsonResponse({ error: 'Method not allowed' }, 405);
});
} else if (path === '/api/feed/funding') {
response = await handleFundingFeed(request, env);
} else if (path === '/api/notifications') {
@ -443,6 +440,8 @@ export default {
response = await handleRmiHealth(request, env);
} else if (path === '/api/rmi/news' || path.startsWith('/api/rmi/news/')) {
response = await handleRmiNews(request, env);
} else if (path === '/api/notify') {
response = await handleNotify(request, env);
} else if (path === '/api/newsletter/subscribe') {
response = await handleNewsletterSubscribe(request, env);
} else if (path === '/api/newsletter/status') {

View file

@ -0,0 +1,103 @@
/**
* /api/account GDPR data portability + deletion
*
* POST /api/account/export returns all user data (preferences, subscriptions, follows, mutes, identities)
* POST /api/account/delete deletes all user data from KV + returns confirmation
*
* Both require a valid session cookie.
*/
import { type Env, jsonResponse, noCacheJsonResponse } from '../index';
import { requireSession } from '../session';
export async function handleAccountExport(request: Request, env: Env): Promise<Response> {
const session = await requireSession(request, env);
if (session instanceof Response) return session;
const data: Record<string, unknown> = {
address: session.address,
chain: session.chain,
exportedAt: new Date().toISOString(),
preferences: null,
subscriptions: null,
follows: null,
mutes: null,
};
if (env.CACHE) {
try {
const [prefs, follows, mutedAuthors, mutedWords] = await Promise.all([
env.CACHE.get(`user:prefs:${session.address}`),
env.CACHE.get(`user:follows:${session.address}`),
env.CACHE.get(`user:muted_authors:${session.address}`),
env.CACHE.get(`user:muted_words:${session.address}`),
]);
data.preferences = prefs ? JSON.parse(prefs) : null;
data.follows = follows ? JSON.parse(follows) : null;
data.mutedAuthors = mutedAuthors ? JSON.parse(mutedAuthors) : null;
data.mutedWords = mutedWords ? JSON.parse(mutedWords) : null;
} catch {
/* ignore */
}
}
if (env.SUBSCRIPTIONS) {
try {
const subKeys = ['newsletter:sub', 'notify:sub'];
const subs: Record<string, unknown> = {};
for (const prefix of subKeys) {
const raw = await env.SUBSCRIPTIONS.get(`${prefix}:${session.address}`);
if (raw) subs[prefix] = JSON.parse(raw);
}
data.subscriptions = subs;
} catch {
/* ignore */
}
}
return noCacheJsonResponse({ data });
}
export async function handleAccountDelete(request: Request, env: Env): Promise<Response> {
const session = await requireSession(request, env);
if (session instanceof Response) return session;
const keysDeleted: string[] = [];
if (env.CACHE) {
const prefix = `user:`;
try {
for (const suffix of ['prefs', 'follows', 'muted_authors', 'muted_words']) {
const key = `${prefix}${suffix}:${session.address}`;
await env.CACHE.delete(key);
keysDeleted.push(key);
}
} catch {
/* ignore */
}
}
if (env.SUBSCRIPTIONS) {
try {
for (const pfx of ['newsletter:sub:', 'notify:sub:']) {
const key = `${pfx}${session.address}`;
const existing = await env.SUBSCRIPTIONS.get(key);
if (existing) {
await env.SUBSCRIPTIONS.delete(key);
keysDeleted.push(key);
}
}
} catch {
/* ignore */
}
}
return new Response(JSON.stringify({ ok: true, deleted: keysDeleted }), {
status: 200,
headers: {
'content-type': 'application/json',
'set-cookie': '__Host-dfid=; Max-Age=0; Path=/; HttpOnly; Secure; SameSite=Lax',
'cache-control': 'no-store',
},
});
}

View file

@ -0,0 +1,70 @@
/**
* /api/notify Landing page email subscription
*
* The landing page at degenfeed.xyz posts { email, source } to this
* endpoint. Emails are stored in the SUBSCRIPTIONS KV namespace under
* `notify:sub:<safeKey(email)>`. A Notion/Postmark integration can
* then pull and send launch emails.
*/
import { type Env, jsonResponse } from '../index';
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
const MAX_EMAILS = 10000;
function safeKey(s: string): string {
return s.toLowerCase().replace(/[^a-z0-9@._-]/g, '_');
}
export async function handleNotify(request: Request, env: Env): Promise<Response> {
if (request.method !== 'POST') {
return jsonResponse({ error: 'Method not allowed' }, 405);
}
let email = '';
try {
const body = (await request.json()) as { email?: string; source?: string };
email = (body.email ?? '').trim().toLowerCase();
} catch {
return jsonResponse({ error: 'Invalid JSON' }, 400);
}
if (!email || email.length > 254 || !EMAIL_RE.test(email)) {
return jsonResponse({ error: 'Invalid email' }, 400);
}
if (!env.SUBSCRIPTIONS) {
// KV not bound — acknowledged locally, no persistence.
return jsonResponse({ ok: true, stored: false }, 200);
}
try {
// Guard against abuse: cap at MAX_EMAILS
const countKey = 'notify:count';
const countRaw = await env.SUBSCRIPTIONS.get(countKey);
const current = countRaw ? Number.parseInt(countRaw, 10) : 0;
if (current >= MAX_EMAILS) {
return jsonResponse({ error: 'Email list full' }, 429);
}
const key = `notify:sub:${safeKey(email)}`;
const existing = await env.SUBSCRIPTIONS.get(key);
if (!existing) {
await env.SUBSCRIPTIONS.put(countKey, String(current + 1));
}
await env.SUBSCRIPTIONS.put(
key,
JSON.stringify({
email,
subscribedAt: Date.now(),
source: 'landing' as const,
}),
{ expirationTtl: 60 * 60 * 24 * 400 },
);
return jsonResponse({ ok: true, stored: true }, 200);
} catch {
return jsonResponse({ ok: true, stored: false }, 200);
}
}

View file

@ -121,6 +121,18 @@ export function clearSessionCookie(): string {
return `${COOKIE_NAME}=; Max-Age=0; Path=/; HttpOnly; Secure; SameSite=Lax`;
}
/**
* Re-issue the session cookie with a fresh 7-day TTL. Call this after
* every authenticated request so active users never get logged out.
*/
export async function refreshSessionCookie(
payload: SessionPayload,
secret: string,
): Promise<string> {
const { setCookie } = await createSession(payload.address, payload.chain, secret);
return setCookie;
}
export async function handleSessionSignOut(_request: Request, _env: Env): Promise<Response> {
return new Response(JSON.stringify({ success: true }), {
status: 200,
@ -147,3 +159,23 @@ export async function requireSession(
return payload;
}
/**
* Take an existing response and attach a refreshed session cookie
* (sliding 7-day TTL). Call this after any authenticated handler
* to keep the session alive.
*/
export async function applySessionRefresh(
response: Response,
payload: SessionPayload,
secret: string,
): Promise<Response> {
const setCookie = await refreshSessionCookie(payload, secret);
const headers = new Headers(response.headers);
headers.set('set-cookie', setCookie);
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}