import { useState, useEffect } from 'react' import { api } from '../lib/api' import { useDocumentTitle } from '../hooks/useDocumentTitle' import { Link } from 'react-router-dom' interface DataField { field: string purpose: string retention: string deletable: boolean } interface Manifest { anonymousUser: DataField[] passkeyUser: DataField[] cookieInfo: string dataLocation: string thirdParties: string[] neverStored: string[] securityHeaders: Record } function Section({ title, children }: { title: string; children: React.ReactNode }) { return (

{title}

{children}
) } function FieldTable({ fields }: { fields: DataField[] }) { return (
{fields.map((f) => (
{f.field} {f.deletable && ( deletable )}

{f.purpose}

Retained: {f.retention}

))}
) } function PrivacySkeleton() { return (
{[0, 1, 2, 3].map((i) => (
))}
) } export default function PrivacyPage() { useDocumentTitle('Privacy') const [manifest, setManifest] = useState(null) const [loading, setLoading] = useState(true) useEffect(() => { api.get('/privacy/data-manifest') .then(setManifest) .catch(() => {}) .finally(() => setLoading(false)) }, []) const body = (color: string) => ({ color, lineHeight: '1.7', fontSize: 'var(--text-sm)' as const }) return (

Privacy

This page is generated from the application's actual configuration. It cannot drift out of sync with reality.

{loading ? ( <>
) : ( <>

{manifest?.cookieInfo || 'This site uses exactly one cookie. It contains a random identifier used to connect you to your posts. It is a session cookie - it expires when you close your browser. It is not used for tracking, analytics, or advertising. No other cookies are set, ever.'}

The cookie is httpOnly (JavaScript cannot read it), Secure (only sent over HTTPS), and SameSite=Strict (never sent on cross-origin requests). Dark mode preference is stored in localStorage, not as a cookie.

Passkeys use the WebAuthn standard. When you register, your device generates a public/private key pair. The private key stays on your device (or syncs via iCloud Keychain, Google Password Manager, Windows Hello). The server only stores the public key.

To log in, your device signs a challenge with the private key. The server verifies it against the stored public key. No email, no password, no personal data. Passkeys are phishing-resistant because they are bound to this domain and cannot be used on any other site.

{manifest?.anonymousUser ? ( ) : (
  • - Token hash (SHA-256, irreversible)
  • - Display name (encrypted at rest)
  • - Dark mode preference
  • - Creation timestamp
)}
{manifest?.passkeyUser ? ( ) : (
  • - Username (encrypted + blind indexed)
  • - Display name (encrypted)
  • - Passkey credential ID (encrypted + blind indexed)
  • - Passkey public key (encrypted)
  • - Passkey counter, device type, backup flag
  • - Passkey transports (encrypted)
  • - Dark mode preference
  • - Creation timestamp
)}
{(manifest?.neverStored || [ 'Email address', 'IP address', 'Browser fingerprint', 'User-agent string', 'Referrer URL', 'Geolocation', 'Device identifiers', 'Behavioral data', 'Session replays', 'Third-party tracking identifiers', ]).map((item) => ( {item} ))}

IP addresses are used for rate limiting in memory only - they never touch the database. Request logs record method, path, and status code, but the IP is stripped before the log line is written.

Every field that could identify a user is encrypted at rest using AES-256-GCM. This includes display names, usernames, passkey credentials, and push subscription endpoints. The anonymous token is stored as a one-way SHA-256 hash (irreversible, even stronger than encryption).

Posts, comments, vote counts, and board metadata are not encrypted because they are public content visible to every visitor. Encrypting them would add latency without a security benefit.

If someone gains access to the raw database, they see ciphertext blobs for identity fields and SHA-256 hashes for tokens. They cannot determine who wrote what, reconstruct display names, or extract passkey credentials.

Export: Download everything this system holds about you as a JSON file from your settings page. The file includes your posts, comments, votes, reactions, display name, and the data manifest.

Delete: One click deletes your identity. Your votes and reactions are removed, comments are anonymized to "[deleted]", posts become "[deleted by author]", and your user record is purged from the database. Not soft-deleted - actually removed.

{Object.entries(manifest?.securityHeaders || { 'Content-Security-Policy': "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-src 'none'; object-src 'none'", 'Referrer-Policy': 'no-referrer', 'X-Content-Type-Options': 'nosniff', 'X-Frame-Options': 'DENY', 'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload', 'Permissions-Policy': 'camera=(), microphone=(), geolocation=(), interest-cohort=()', 'Cross-Origin-Opener-Policy': 'same-origin', }).map(([header, value]) => (
{header} {value}
))}

No external domains are whitelisted. The client never contacts a third party.

{manifest?.thirdParties && manifest.thirdParties.length > 0 ? (
    {manifest.thirdParties.map((tp) => (
  • - {tp}
  • ))}
) : (

None. No external HTTP requests from the client, ever. No Google Fonts, no CDN scripts, no analytics, no tracking pixels. The only outbound requests the server makes are to external APIs configured by installed plugins (always your own infrastructure) and to push notification endpoints (browser-generated URLs).

)}
)}
) }