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/(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/(app)/faq/page.tsx b/app/(info)/faq/page.tsx
similarity index 73%
rename from app/(app)/faq/page.tsx
rename to app/(info)/faq/page.tsx
index 5c38d1f..08e47f5 100644
--- a/app/(app)/faq/page.tsx
+++ b/app/(info)/faq/page.tsx
@@ -1,7 +1,11 @@
+import type { Metadata } from "next";
import { CircleHelp, ShieldCheck, Boxes } from "lucide-react";
import { Section } from "@/components/Section";
-export const dynamic = "force-dynamic";
+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 (
@@ -23,15 +27,15 @@ export default function FaqPage() {
@@ -53,7 +57,7 @@ export default function FaqPage() {
+
+
+
+
+ );
+}
+
+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
+
+ .
+
+
+
+ );
+}
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/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}`);
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/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() {
Read the SDK docs
@@ -288,7 +288,7 @@ export default async function LandingPage() {
{/* Footer */}