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:
parent
d4d7a448cd
commit
f2fcf7d9c2
9 changed files with 455 additions and 36 deletions
76
.github/renovate.json
vendored
Normal file
76
.github/renovate.json
vendored
Normal 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
|
||||
}
|
||||
2
apps/web/next-env.d.ts
vendored
2
apps/web/next-env.d.ts
vendored
|
|
@ -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.
|
||||
|
|
|
|||
75
apps/web/src/app/privacy/page.tsx
Normal file
75
apps/web/src/app/privacy/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
apps/web/src/app/terms/page.tsx
Normal file
56
apps/web/src/app/terms/page.tsx
Normal 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 “as is” 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
103
workers/api/src/routes/account.ts
Normal file
103
workers/api/src/routes/account.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
70
workers/api/src/routes/notify.ts
Normal file
70
workers/api/src/routes/notify.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue