From 8ac01cb8f0db60400d34b2adecc5ac65e069e11d Mon Sep 17 00:00:00 2001 From: Matthew Demidoff Date: Thu, 25 Jun 2026 22:06:09 +0200 Subject: [PATCH 1/4] fix(auth): reject banned users on the Telegram login callback The login-widget callback minted a session straight from a Telegram-signed payload without checking account status, and findSessionByToken did not exclude banned users, so a banned account whose Telegram stayed linked could re-enter every page and server action. Check status and the telegram_id ban on the callback, and exclude banned users at session resolution. --- app/(app)/faq/page.tsx | 65 ---------------------- app/api/telegram/callback/route.ts | 9 +++ lib/server/repositories/sessions.ts | 5 ++ tests/unit/telegram-callback-ban.test.ts | 70 ++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 65 deletions(-) delete mode 100644 app/(app)/faq/page.tsx create mode 100644 tests/unit/telegram-callback-ban.test.ts diff --git a/app/(app)/faq/page.tsx b/app/(app)/faq/page.tsx deleted file mode 100644 index 5c38d1f..0000000 --- a/app/(app)/faq/page.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { CircleHelp, ShieldCheck, Boxes } from "lucide-react"; -import { Section } from "@/components/Section"; - -export const dynamic = "force-dynamic"; - -function QA({ q, a }: { q: string; a: string }) { - return ( -
-
{q}
-
{a}
-
- ); -} - -export default function FaqPage() { - return ( - <> -
-

FAQ

-

Answers to common questions

-
- -
- - - -
- -
- - - -
- -
- - -
- - ); -} diff --git a/app/api/telegram/callback/route.ts b/app/api/telegram/callback/route.ts index e9fe81b..9268ace 100644 --- a/app/api/telegram/callback/route.ts +++ b/app/api/telegram/callback/route.ts @@ -1,6 +1,7 @@ import { NextResponse, type NextRequest } from "next/server"; import { verifyTelegramLogin } from "@/lib/server/telegram"; import { findUserByTelegramId, linkTelegram } from "@/lib/server/repositories/users"; +import { isTelegramIdBanned } from "@/lib/server/repositories/bans"; import { createUserSession, getSessionFromRequest } from "@/lib/server/session"; import { authBaseUrl } from "@/lib/server/config"; import { requestContext } from "@/lib/server/http"; @@ -42,6 +43,14 @@ export async function GET(req: NextRequest) { if (!user) { return NextResponse.redirect(new URL("/register?error=telegram_unlinked", base)); } + // Every other login sink (password, passkey, OAuth, bot-push 2FA) rejects a + // banned principal before issuing a session. This branch resolves the user + // straight from a Telegram-signed payload the banned owner can re-issue at + // will, so it must enforce the same gate - including the telegram_id ban that + // outlives account recreation - or a ban can be self-served away. + if (user.status === "banned" || (await isTelegramIdBanned(telegram.id))) { + return NextResponse.redirect(new URL("/login?error=telegram", base)); + } const res = NextResponse.redirect(new URL("/", base)); await createUserSession(user.id, req, res); diff --git a/lib/server/repositories/sessions.ts b/lib/server/repositories/sessions.ts index c149a14..984aeb9 100644 --- a/lib/server/repositories/sessions.ts +++ b/lib/server/repositories/sessions.ts @@ -85,6 +85,10 @@ export async function createSession(input: { return mapSession(row); } +// Resolving a session also excludes banned users: pages and server actions +// gate on session presence alone (only the bearer/REST APIs re-check status), +// so treating a banned principal as logged-out here is the single chokepoint +// that keeps a ban enforced across the whole authenticated surface. export async function findSessionByToken(token: string) { const row = await queryOne( `update sessions s @@ -95,6 +99,7 @@ export async function findSessionByToken(token: string) { and s.revoked_at is null and s.expires_at > $2 and s.last_seen_at > now() - make_interval(secs => $3) + and u.status <> 'banned' returning s.id as session_id, s.user_id, diff --git a/tests/unit/telegram-callback-ban.test.ts b/tests/unit/telegram-callback-ban.test.ts new file mode 100644 index 0000000..92e52e0 --- /dev/null +++ b/tests/unit/telegram-callback-ban.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Avoid a real ioredis connection at import time and keep every dependency the +// route touches stubbed, so this exercises only the ban gate in the login +// branch (a banned principal whose Telegram is still linked can self-issue a +// valid signed payload, so the route - not just session creation - must block). +vi.mock('@/lib/server/redis', () => ({ default: {} })); + +// Signature validity is assumed; it is not what this test covers. +vi.mock('@/lib/server/telegram', () => ({ + verifyTelegramLogin: vi.fn(() => ({ id: '777', username: 'u', firstName: 'U' })), +})); + +const findUserByTelegramId = vi.fn(); +vi.mock('@/lib/server/repositories/users', () => ({ + findUserByTelegramId: (...a: unknown[]) => findUserByTelegramId(...a), + linkTelegram: vi.fn(), +})); + +const isTelegramIdBanned = vi.fn(); +vi.mock('@/lib/server/repositories/bans', () => ({ + isTelegramIdBanned: (...a: unknown[]) => isTelegramIdBanned(...a), +})); + +const createUserSession = vi.fn(); +vi.mock('@/lib/server/session', () => ({ + createUserSession: (...a: unknown[]) => createUserSession(...a), + // No existing session -> the route takes the login branch. + getSessionFromRequest: vi.fn(async () => null), +})); + +vi.mock('@/lib/server/config', () => ({ authBaseUrl: () => 'https://auth.example' })); +vi.mock('@/lib/server/http', () => ({ + requestContext: () => ({ ip: '', userAgent: '', country: '' }), +})); +vi.mock('@/lib/server/repositories/securityEvents', () => ({ recordSecurityEvent: vi.fn() })); + +import { GET } from '@/app/api/telegram/callback/route'; + +const req = () => ({ nextUrl: { searchParams: new URLSearchParams('id=777') } }) as never; + +describe('GET /api/telegram/callback login branch', () => { + beforeEach(() => { + findUserByTelegramId.mockReset(); + isTelegramIdBanned.mockReset().mockResolvedValue(false); + createUserSession.mockReset(); + }); + + it('refuses a session for a banned user', async () => { + findUserByTelegramId.mockResolvedValue({ id: 1, status: 'banned' }); + const res = await GET(req()); + expect(createUserSession).not.toHaveBeenCalled(); + expect(res.headers.get('location')).toBe('https://auth.example/login?error=telegram'); + }); + + it('refuses a session when the telegram id is banned, even for a non-banned row', async () => { + findUserByTelegramId.mockResolvedValue({ id: 2, status: 'active' }); + isTelegramIdBanned.mockResolvedValue(true); + const res = await GET(req()); + expect(createUserSession).not.toHaveBeenCalled(); + expect(res.headers.get('location')).toBe('https://auth.example/login?error=telegram'); + }); + + it('issues a session for an active, un-banned user', async () => { + findUserByTelegramId.mockResolvedValue({ id: 3, status: 'active' }); + const res = await GET(req()); + expect(createUserSession).toHaveBeenCalledOnce(); + expect(res.headers.get('location')).toBe('https://auth.example/'); + }); +}); From c595bee4140e2d234a857c4c66f1428824517821 Mon Sep 17 00:00:00 2001 From: Matthew Demidoff Date: Thu, 25 Jun 2026 22:06:09 +0200 Subject: [PATCH 2/4] fix(auth): require an unrestricted session for passkey registration and relink These routes used a raw session lookup that skipped the requireUser gate, letting a banned or restricted account add a passkey or change its Telegram link. Route them through requireUser. --- app/api/auth/relink/initiate/route.ts | 8 ++++---- app/api/auth/relink/verify/route.ts | 8 ++++---- app/api/auth/webauthn/register/generate-options/route.ts | 8 ++++---- app/api/auth/webauthn/register/verify/route.ts | 8 ++++---- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/app/api/auth/relink/initiate/route.ts b/app/api/auth/relink/initiate/route.ts index 191640b..2e99037 100644 --- a/app/api/auth/relink/initiate/route.ts +++ b/app/api/auth/relink/initiate/route.ts @@ -1,8 +1,8 @@ import { type NextRequest } from "next/server"; -import { getSessionFromRequest } from "@/lib/server/session"; +import { requireUser } from "@/lib/server/apiAuth"; import { createRelinkOtp } from "@/lib/server/relinkChallenge"; import { sendTelegramMessage } from "@/lib/server/telegramSend"; -import { forbidden, json, tooManyRequests, requestContext } from "@/lib/server/http"; +import { json, tooManyRequests, requestContext } from "@/lib/server/http"; import { rateLimit } from "@/lib/server/rateLimit"; export const runtime = "nodejs"; @@ -16,8 +16,8 @@ const IP_LIMIT = 10; const IP_WINDOW_MS = 60 * 60 * 1000; export async function POST(req: NextRequest) { - const session = await getSessionFromRequest(req); - if (!session) return forbidden(); + const { response, session } = await requireUser(req); + if (response) return response; const { user } = session; const ctx = requestContext(req); diff --git a/app/api/auth/relink/verify/route.ts b/app/api/auth/relink/verify/route.ts index 2a2a65c..7099c44 100644 --- a/app/api/auth/relink/verify/route.ts +++ b/app/api/auth/relink/verify/route.ts @@ -1,14 +1,14 @@ import { type NextRequest } from "next/server"; -import { getSessionFromRequest } from "@/lib/server/session"; +import { requireUser } from "@/lib/server/apiAuth"; import { verifyRelinkOtp, createRelinkChallenge } from "@/lib/server/relinkChallenge"; -import { forbidden, json, requestBody } from "@/lib/server/http"; +import { json, requestBody } from "@/lib/server/http"; import { env } from "@/lib/server/config"; export const runtime = "nodejs"; export async function POST(req: NextRequest) { - const session = await getSessionFromRequest(req); - if (!session) return forbidden(); + const { response, session } = await requireUser(req); + if (response) return response; const body = await requestBody(req); const code = typeof body.code === "string" ? body.code : ""; diff --git a/app/api/auth/webauthn/register/generate-options/route.ts b/app/api/auth/webauthn/register/generate-options/route.ts index 6b753c9..5309bbd 100644 --- a/app/api/auth/webauthn/register/generate-options/route.ts +++ b/app/api/auth/webauthn/register/generate-options/route.ts @@ -1,6 +1,6 @@ import { NextResponse, type NextRequest } from "next/server"; import { generateRegistrationOptions, type AuthenticatorTransport } from "@simplewebauthn/server"; -import { getSessionFromRequest } from "@/lib/server/session"; +import { requireUser } from "@/lib/server/apiAuth"; import { findWebauthnCredentialsByUser } from "@/lib/server/repositories/webauthn"; import { getRpID, rpName } from "@/lib/server/webauthn"; import redis from "@/lib/server/redis"; @@ -10,9 +10,9 @@ export const runtime = "nodejs"; export async function GET(req: NextRequest) { try { - const session = await getSessionFromRequest(req); - if (!session) { - return json({ error: "unauthorized" }, 401); + const { response, session } = await requireUser(req); + if (response) { + return response; } const userCredentials = await findWebauthnCredentialsByUser(session.user.id); diff --git a/app/api/auth/webauthn/register/verify/route.ts b/app/api/auth/webauthn/register/verify/route.ts index e308a9b..33af591 100644 --- a/app/api/auth/webauthn/register/verify/route.ts +++ b/app/api/auth/webauthn/register/verify/route.ts @@ -1,6 +1,6 @@ import { NextResponse, type NextRequest } from "next/server"; import { verifyRegistrationResponse } from "@simplewebauthn/server"; -import { getSessionFromRequest } from "@/lib/server/session"; +import { requireUser } from "@/lib/server/apiAuth"; import { createWebauthnCredential } from "@/lib/server/repositories/webauthn"; import { getRpID, getOrigin } from "@/lib/server/webauthn"; import redis from "@/lib/server/redis"; @@ -12,9 +12,9 @@ export const runtime = "nodejs"; export async function POST(req: NextRequest) { try { - const session = await getSessionFromRequest(req); - if (!session) { - return json({ error: "unauthorized" }, 401); + const { response, session } = await requireUser(req); + if (response) { + return response; } const body = await requestBody(req); const expectedChallenge = await redis.get(`webauthn:challenge:${session.user.id}`); From 9d9e351c3a15cc9114461070ff12019fc41a565e Mon Sep 17 00:00:00 2001 From: Matthew Demidoff Date: Thu, 25 Jun 2026 22:06:10 +0200 Subject: [PATCH 3/4] fix(oauth): separate app API key from OAuth client secret New apps and DCR approvals stored one secret hash in both api_key_hash and oauth_client_secret_hash, so leaking one credential compromised both surfaces and rotating the OAuth secret left the old API key valid. Issue independent secrets; bearer-only apps now store a null OAuth secret. --- app/(app)/developers/apps/new/actions.ts | 8 +++++++- app/admin/(panel)/oauth-clients/actions.ts | 5 ++++- lib/server/repositories/externalApps.ts | 9 ++++++--- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/app/(app)/developers/apps/new/actions.ts b/app/(app)/developers/apps/new/actions.ts index a798d44..8d99216 100644 --- a/app/(app)/developers/apps/new/actions.ts +++ b/app/(app)/developers/apps/new/actions.ts @@ -33,6 +33,11 @@ export async function createAppAction(formData: FormData) { const clientId = `app_${randomToken(16)}`; const clientSecret = `sec_${randomToken(32)}`; + // The api key (activation/bearer API) and the OAuth client secret guard + // different surfaces, so they are independent secrets - leaking one must not + // authenticate the other. The api key satisfies the not-null column here; it + // is the request-bearer flow that issues an api key to the user. + const apiKey = `sec_${randomToken(32)}`; const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "") + "-" + randomToken(4); const row = await queryOne<{ id: string }>( @@ -51,11 +56,12 @@ export async function createAppAction(formData: FormData) { oauth_profile_version, status, owner_user_id - ) values ($1, $2, $3, $4, $4, $5, 'confidential', 'client_secret_post', $6, $7, true, 'bn-oauth-2026-05', 'active', $8) returning id`, + ) values ($1, $2, $3, $4, $5, $6, 'confidential', 'client_secret_post', $7, $8, true, 'bn-oauth-2026-05', 'active', $9) returning id`, [ clientId, name, slug, + hashToken(apiKey), hashToken(clientSecret), [redirectUri], ["authorization_code", "refresh_token", "client_credentials", "urn:ietf:params:oauth:grant-type:device_code"], diff --git a/app/admin/(panel)/oauth-clients/actions.ts b/app/admin/(panel)/oauth-clients/actions.ts index 90a4e09..a6c7061 100644 --- a/app/admin/(panel)/oauth-clients/actions.ts +++ b/app/admin/(panel)/oauth-clients/actions.ts @@ -29,11 +29,14 @@ export async function approveOAuthClientRegistrationAction(formData: FormData) { const clientSecret = request.clientType === "confidential" ? `sec_${randomToken(32)}` : null; + // The api key and the OAuth client secret authenticate different surfaces, so + // they must stay independent - never reuse the client secret as the api key. + const apiKey = `sec_${randomToken(32)}`; const approved = await approveOAuthClientRegistrationRequest({ id, publicId: request.publicId, slug: slugFor(request.clientName), - apiKey: clientSecret || `sec_${randomToken(32)}`, + apiKey, clientSecret, reviewedByUserId: current.user.id, }); diff --git a/lib/server/repositories/externalApps.ts b/lib/server/repositories/externalApps.ts index be5dbd3..89296cf 100644 --- a/lib/server/repositories/externalApps.ts +++ b/lib/server/repositories/externalApps.ts @@ -166,16 +166,19 @@ export async function createExternalApp(input: { ownerUserId: number; apiKey: string; }) { + // Bearer/activation apps authenticate only via their api key. They are not + // OAuth confidential clients (no redirect URIs, no client_credentials grant), + // so oauth_client_secret_hash is left null rather than reusing the api-key + // hash - the two surfaces must not share a secret. const row = await queryOne( `insert into external_apps ( public_id, name, slug, owner_user_id, - api_key_hash, - oauth_client_secret_hash + api_key_hash ) - values ($1, $2, $3, $4, $5, $5) + values ($1, $2, $3, $4, $5) returning ${externalAppSelect}`, [ From 0c861210ceb85810e96e316ec348128f51ec62ac Mon Sep 17 00:00:00 2001 From: Matthew Demidoff Date: Thu, 25 Jun 2026 22:06:10 +0200 Subject: [PATCH 4/4] feat: accept Terms, Privacy, and Rules at sign-up and add public info pages Registration now requires accepting the Terms, Privacy Policy, and Rules; acceptance time and version are recorded per account (migration 025). Add public /docs, /faq, /terms, /privacy, /rules pages under app/(info), move FAQ out of the authenticated area, and point the landing page at the public URLs. --- app/(info)/docs/page.tsx | 86 +++++++++++++++++++ app/(info)/faq/page.tsx | 69 +++++++++++++++ app/(info)/layout.tsx | 60 +++++++++++++ app/(info)/privacy/page.tsx | 100 ++++++++++++++++++++++ app/(info)/rules/page.tsx | 99 ++++++++++++++++++++++ app/(info)/terms/page.tsx | 113 +++++++++++++++++++++++++ app/page.tsx | 20 +++-- app/register/page.tsx | 23 +++++ components/Doc.tsx | 37 ++++++++ db/migrations/025_terms_acceptance.sql | 8 ++ db/schema.sql | 2 + lib/server/repositories/users.ts | 8 +- lib/server/terms.ts | 6 ++ lib/server/validation.ts | 8 ++ tests/unit/validation.test.ts | 15 ++++ 15 files changed, 647 insertions(+), 7 deletions(-) create mode 100644 app/(info)/docs/page.tsx create mode 100644 app/(info)/faq/page.tsx create mode 100644 app/(info)/layout.tsx create mode 100644 app/(info)/privacy/page.tsx create mode 100644 app/(info)/rules/page.tsx create mode 100644 app/(info)/terms/page.tsx create mode 100644 components/Doc.tsx create mode 100644 db/migrations/025_terms_acceptance.sql create mode 100644 lib/server/terms.ts diff --git a/app/(info)/docs/page.tsx b/app/(info)/docs/page.tsx new file mode 100644 index 0000000..c052b42 --- /dev/null +++ b/app/(info)/docs/page.tsx @@ -0,0 +1,86 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { DocHeader, DocSection, DocList } from "@/components/Doc"; + +export const metadata: Metadata = { + title: "Documentation — bottleneck", + description: + "Get started with bottleneck: Telegram-confirmed sign-in, passkeys, and an OAuth 2.1 / OpenID Connect provider.", +}; + +export default function DocsPage() { + return ( +
+ + + +

+ bottleneck is an account service with Telegram-confirmed two-factor sign-in and passkeys, + and a full OAuth 2.1 / OpenID Connect provider you can use to sign users into your own + applications. +

+
+ + + + + Create an account + {" "} + and confirm it from Telegram to finish sign-up. + , + "Add a passkey under Password & 2FA to sign in without a password.", + "Each sign-in is approved from your linked Telegram, so a stolen password alone is not enough.", + ]} + /> + + + +

+ bottleneck implements OAuth 2.1 and OpenID Connect. Client metadata is published at the + standard discovery endpoint: +

+

+ /.well-known/openid-configuration +

+ +
+ + +

+ For server-to-server access you can request a long-lived API bearer token. Each request is + reviewed before a token is issued. API keys and OAuth client secrets are separate + credentials — keep both confidential and rotate them if exposed. +

+
+ + +

+ Official client libraries are available for Node.js and Go to verify tokens and call the + API. See the SDK directories in the project for usage. +

+
+ + +

+ Browse the{" "} + + FAQ + {" "} + for common questions, or reach{" "} + + support + {" "} + if you are stuck. +

+
+
+ ); +} diff --git a/app/(info)/faq/page.tsx b/app/(info)/faq/page.tsx new file mode 100644 index 0000000..08e47f5 --- /dev/null +++ b/app/(info)/faq/page.tsx @@ -0,0 +1,69 @@ +import type { Metadata } from "next"; +import { CircleHelp, ShieldCheck, Boxes } from "lucide-react"; +import { Section } from "@/components/Section"; + +export const metadata: Metadata = { + title: "FAQ — bottleneck", + description: "Answers to common questions about bottleneck accounts, security, and OAuth apps.", +}; + +function QA({ q, a }: { q: string; a: string }) { + return ( +
+
{q}
+
{a}
+
+ ); +} + +export default function FaqPage() { + return ( + <> +
+

FAQ

+

Answers to common questions

+
+ +
+ + + +
+ +
+ + + +
+ +
+ + +
+ + ); +} diff --git a/app/(info)/layout.tsx b/app/(info)/layout.tsx new file mode 100644 index 0000000..ffafe85 --- /dev/null +++ b/app/(info)/layout.tsx @@ -0,0 +1,60 @@ +import Link from "next/link"; +import { TERMS_EFFECTIVE, TERMS_VERSION } from "@/lib/server/terms"; + +// Public chrome for the information pages. These are reachable without a +// session, so they carry their own header/footer rather than the app shell. + +function Monogram() { + return ( + + ); +} + +const NAV = [ + { href: "/docs", label: "Docs" }, + { href: "/faq", label: "FAQ" }, + { href: "/terms", label: "Terms" }, + { href: "/privacy", label: "Privacy" }, + { href: "/rules", label: "Rules" }, +]; + +export default function InfoLayout({ children }: { children: React.ReactNode }) { + return ( +
+
+
+ + + bottleneck + + +
+
+ +
{children}
+ +
+
+ bottleneck · {new Date().getFullYear()} + + Terms version {TERMS_VERSION} · effective {TERMS_EFFECTIVE} + +
+
+
+ ); +} diff --git a/app/(info)/privacy/page.tsx b/app/(info)/privacy/page.tsx new file mode 100644 index 0000000..b7ba449 --- /dev/null +++ b/app/(info)/privacy/page.tsx @@ -0,0 +1,100 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { DocHeader, DocSection, DocList } from "@/components/Doc"; +import { TERMS_EFFECTIVE } from "@/lib/server/terms"; + +export const metadata: Metadata = { + title: "Privacy Policy — bottleneck", + description: "How bottleneck collects, uses, and protects your data.", +}; + +export default function PrivacyPage() { + return ( +
+ + + + + + + + + + + +

We use a small number of processors to run the Service:

+ +
+ + +

+ When you authorize a third-party application through OAuth, we share only the information + covered by the scopes you approve (for example your profile, email, or date of birth). + We do not sell your personal data. +

+
+ + +

+ We keep your account data for as long as your account exists. Security event records are + retained for a limited period to investigate abuse. When you request deletion, your + account enters a short grace period and is then permanently removed; some records may be + retained where required for security or by law. +

+
+ + + + + + +

+ We may update this Privacy Policy. Material changes are reflected in the version and + effective date shown here. +

+
+ + +

+ For privacy questions, reach us through{" "} + + support + + . See also the{" "} + + Terms of Service + {" "} + and{" "} + + Rules + + . +

+
+
+ ); +} diff --git a/app/(info)/rules/page.tsx b/app/(info)/rules/page.tsx new file mode 100644 index 0000000..137f526 --- /dev/null +++ b/app/(info)/rules/page.tsx @@ -0,0 +1,99 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { DocHeader, DocSection, DocList } from "@/components/Doc"; +import { TERMS_EFFECTIVE } from "@/lib/server/terms"; + +export const metadata: Metadata = { + title: "Rules — bottleneck", + description: "Acceptable use rules for the bottleneck account service.", +}; + +export default function RulesPage() { + return ( +
+ + + +

+ bottleneck is an account and sign-in service. Use it for your own legitimate access, + treat other people fairly, and do not attack the service or the people who rely on it. + Breaking these rules can cost you your account. +

+
+ + + + + + + + + + + + + + +

+ Violating these rules - including providing an invalid date of birth, or exploiting or + abusing the service or other people through it - may result in immediate{" "} + suspension and/or a permanent ban, at our discretion + and without prior notice. Where appropriate we may also remove content, revoke issued + tokens, and report conduct to the relevant authorities. +

+

+ Bans can be enforced by your linked Telegram identity, so a ban can survive deleting and + recreating an account. Evading a ban is itself a violation. +

+
+ + +

+ We welcome good-faith security research. If you find a vulnerability, report it privately + rather than exploiting it or disclosing it publicly, and give us reasonable time to fix + it. Good-faith research that follows the disclosure process in our security policy will + not be penalized under these rules. You can reach us through{" "} + + support + + . +

+
+ + +

+ These Rules are part of the{" "} + + Terms of Service + + . See also the{" "} + + Privacy Policy + + . +

+
+
+ ); +} diff --git a/app/(info)/terms/page.tsx b/app/(info)/terms/page.tsx new file mode 100644 index 0000000..6319d77 --- /dev/null +++ b/app/(info)/terms/page.tsx @@ -0,0 +1,113 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { DocHeader, DocSection, DocList } from "@/components/Doc"; +import { TERMS_EFFECTIVE } from "@/lib/server/terms"; + +export const metadata: Metadata = { + title: "Terms of Service — bottleneck", + description: "The terms that govern your use of the bottleneck account service.", +}; + +export default function TermsPage() { + return ( +
+ + + +

+ These Terms of Service govern your access to and use of bottleneck (the “Service”). + By creating an account or using the Service you agree to these Terms, the{" "} + + Privacy Policy + + , and the{" "} + + Rules + + , which are incorporated here by reference. If you do not agree, do not use the Service. +

+
+ + +

+ You must be old enough to form a binding agreement where you live and able to use the + Service under applicable law. You must provide accurate registration details, including a + valid date of birth. +

+
+ + + + + + +

+ Your use of the Service is subject to the{" "} + + Rules + + . In short: provide accurate information, do not exploit or abuse the Service, and do not + use it to harm other people. We may suspend or terminate accounts that violate the Rules. +

+
+ + + + + + +

+ We may suspend or terminate your access at any time for violations of these Terms or the + Rules, to protect the Service or its users, or as required by law. You may stop using the + Service and delete your account at any time from your account settings. +

+
+ + +

+ The Service is provided on an “as is” and “as available” basis + without warranties of any kind, to the fullest extent permitted by law. We do not warrant + that the Service will be uninterrupted, error-free, or secure against every threat. +

+
+ + +

+ To the fullest extent permitted by law, we are not liable for any indirect, incidental, + or consequential damages, or for loss of data, arising from your use of or inability to + use the Service. +

+
+ + +

+ We may update these Terms. When we make material changes we will revise the version and + effective date shown here; continuing to use the Service after a change means you accept + the updated Terms. +

+
+ + +

+ Questions about these Terms can be raised through{" "} + + support + + . +

+
+
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index 091afad..902e044 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -141,7 +141,7 @@ export default async function LandingPage() {