-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- >
- );
-}
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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
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}
+
+
+
+ );
+}
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
+
+ .
+