diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..793d8a5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +.next +.source +.env* +!.env.example +Dockerfile +.dockerignore diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..69ab87b --- /dev/null +++ b/.env.example @@ -0,0 +1,37 @@ +# Copy to .env.local (dev) or pass as container env vars (prod). + +# Public URL of the site, used in llms.txt links, canonical URLs and OG metadata +NEXT_PUBLIC_SITE_URL=https://docs.pangolin.net + +# --------------------------------------------------------------------------- +# AI assistant (bring your own key). Set ONE provider. Keys stay on the server. +# --------------------------------------------------------------------------- + +# Optional: anthropic | openai | google | openai-compatible +# (inferred from whichever key below is set) +# AI_PROVIDER=anthropic + +# Optional: model id for the provider (defaults: openai=gpt-5.6-luna, +# anthropic=claude-opus-5, google=gemini-2.5-pro; required for openai-compatible) +# AI_MODEL=gpt-5.6-luna + +# OpenAI +# OPENAI_API_KEY= + +# Anthropic +# ANTHROPIC_API_KEY= + +# Google Gemini +# GOOGLE_GENERATIVE_AI_API_KEY= + +# Any OpenAI-compatible endpoint: OpenRouter, Ollama, vLLM, LiteLLM, or a +# Pangolin AI Gateway resource +# AI_BASE_URL=https://openrouter.ai/api/v1 +# AI_API_KEY= +# AI_MODEL=anthropic/claude-opus-5 + +# Requests per minute per IP to /api/chat (0 disables the limiter) +# AI_RATE_LIMIT_PER_MINUTE=10 + +# Set to 1 to disable PostHog / Rybbit / Reo in production builds +# NEXT_PUBLIC_DISABLE_ANALYTICS= diff --git a/.gitignore b/.gitignore index e43b0f9..52d9ddc 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,28 @@ +# deps +/node_modules + +# generated content +.source + +# test & build +/coverage +/.next/ +/out/ +/build +*.tsbuildinfo + +# misc .DS_Store +*.pem +/.pnp +.pnp.js +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# others +.env*.local +.vercel +next-env.d.ts +# local env +.env diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index b39546b..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "cSpell.words": [ - "nessicary" - ] -} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..643577d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,9 @@ + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5316e2d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +# syntax=docker/dockerfile:1 +FROM node:24-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci + +FROM node:24-alpine AS build +WORKDIR /app +ENV NEXT_TELEMETRY_DISABLED=1 +ARG NEXT_PUBLIC_SITE_URL=https://docs.pangolin.net +ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build + +FROM node:24-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production \ + NEXT_TELEMETRY_DISABLED=1 \ + PORT=3000 \ + HOSTNAME=0.0.0.0 +RUN addgroup -S app && adduser -S app -G app +COPY --from=build --chown=app:app /app/.next/standalone ./ +COPY --from=build --chown=app:app /app/.next/static ./.next/static +COPY --from=build --chown=app:app /app/public ./public +USER app +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/README.md b/README.md index 9850e31..cf046a1 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,73 @@ # Pangolin Documentation -This documentation site is built using [Mintlify](https://mintlify.com). If you'd like to contribute to the documentation or run it locally for development, follow the instructions below. +The Pangolin docs site, built with [Fumadocs](https://fumadocs.dev) on Next.js and fully self-hostable. It replaces the old Mintlify site (`docs-v2`) and keeps the same URLs. -### Local Development +## Features -Install the [Mintlify CLI](https://www.npmjs.com/package/mint) to preview your documentation changes locally: +- **Same URLs as Mintlify.** Pages are served from the site root (`/manage/sites/install-site`). The old Mintlify redirects are in `lib/redirects.json`. +- **Full-text search** (Orama, runs in-process) with ⌘K, served from `/api/search`. +- **AI assistant**: a sticky "Ask a question…" bar at the bottom of every page (⌘I) that opens a chat panel. An agent that searches and reads the docs and cites the pages it used. It runs on your own API key with Anthropic, OpenAI, Google, or any OpenAI-compatible endpoint. +- **LLM-friendly output:** + - `/llms.txt`: an index following the sidebar structure + - `/llms-full.txt`: every page in one file + - `/.md`: the Markdown for a single page. Requests sent with `Accept: text/markdown` get the same Markdown. + - Each page has "Copy Markdown" and "Open in ChatGPT / Claude / …" actions. +- Generated Open Graph images for every page (`/og/...`). + +## Development ```bash -npm i -g mint +npm install +cp .env.example .env.local # optional: add an AI key +npm run dev # http://localhost:3000 ``` -Run the following command at the root of your documentation, where your `docs.json` is located: +Other scripts: `npm run build`, `npm start`, `npm run types:check`. + +## Writing docs + +- Pages live in `content/docs/**/*.mdx`. The URL is the file path. +- The **sidebar** is defined in `lib/navigation.json`, using the same group/pages shape as Mintlify's `docs.json`. Mintlify groups don't map to folders, so this file replaces per-folder `meta.json`. A page that isn't listed there is still published; it just doesn't appear in the sidebar. +- The Mintlify components used by the content keep working with the same names and props: `Note`, `Info`, `Tip`, `Warning`, `Check`, `Card`, `CardGroup`, `Columns`, `Steps`/`Step`, `Tabs`/`Tab`, `Accordion`/`AccordionGroup`, `Expandable`, `ResponseField`, `Frame`, `Update`. They're implemented in `components/mintlify.tsx`, and the icon names are mapped from Font Awesome in `components/icons.tsx`. +- Code blocks: ` ```yaml title="config.yml" {3-5}` sets a title and highlights lines 3–5. To make tabbed code, put consecutive blocks with `tab="Name"` in a row (this replaces ``). +- Shared snippets live in `content/snippets/` and are pulled in with `../../snippets/file.mdx`. +- Images go in `public/images/` and are referenced as `/images/...`. + +## AI assistant configuration + +Everything is set with server-side environment variables, so keys are never sent to the browser. See `.env.example`. + +| Variable | Purpose | +| --- | --- | +| `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` / `GOOGLE_GENERATIVE_AI_API_KEY` | Set one of these to choose that provider | +| `AI_BASE_URL` + `AI_API_KEY` | Any OpenAI-compatible API (OpenRouter, Ollama, vLLM, LiteLLM, a Pangolin AI Gateway resource) | +| `AI_PROVIDER` | Force a provider when more than one key is set | +| `AI_MODEL` | Model ID. Defaults: `gpt-5.6-luna`, `claude-opus-5`, `gemini-2.5-pro`. Required for OpenAI-compatible endpoints | +| `AI_RATE_LIMIT_PER_MINUTE` | Per-IP limit on `/api/chat` (default 10, `0` turns it off). The limit is kept in memory for each server instance | + +If no key is set, the rest of the site works normally and the assistant returns a clear "not configured" message. + +How it works (`lib/ai/`): the system prompt contains an index of every page, and the model has two tools. `search_docs` queries the same Orama index as the search bar, and `read_page` returns a page's full Markdown. The route is `app/api/chat/route.ts`, and the UI is in `components/ai/`. + +## Self-hosting ```bash -mint dev +docker compose up -d --build # reads .env if present, serves on :3000 ``` -View your local preview at `http://localhost:3000`. +or without Compose: -### Publishing Changes +```bash +docker build -t pangolin-docs --build-arg NEXT_PUBLIC_SITE_URL=https://docs.pangolin.net . +docker run -p 3000:3000 -e OPENAI_API_KEY=... pangolin-docs +``` -Install our GitHub app from your [dashboard](https://dashboard.mintlify.com/settings/organization/github-app) to propagate changes from your repo to your deployment. Changes are deployed to production automatically after pushing to the default branch. +The build uses Next.js `output: 'standalone'`, so the image only needs Node to run. Pages, llms files, and OG images are prerendered. Only `/api/search` and `/api/chat` run at request time. -### Need Help? +## Syncing from the Mintlify repo -#### Troubleshooting +`scripts/migrate-from-mintlify.py` copies content, images, navigation, and redirects from `docs-v2` and rewrites the Mintlify-only syntax (code fence titles, `highlight=`, ``, snippet imports). It overwrites `content/`, `public/images`, `lib/navigation.json`, and `lib/redirects.json`, so only use it while `docs-v2` is still the source of truth: -- If your dev environment isn't running: Run `mint update` to ensure you have the most recent version of the CLI. -- If a page loads as a 404: Make sure you are running in a folder with a valid `docs.json`. - -#### Resources -- [Mintlify documentation](https://mintlify.com/docs) -- [Mintlify community](https://mintlify.com/community) -- [Pangolin GitHub Repository](https://github.com/fosrl/pangolin) +```bash +npm run migrate # = python3 scripts/migrate-from-mintlify.py ../docs-v2 +``` diff --git a/about/security/fips-fedramp.mdx b/about/security/fips-fedramp.mdx deleted file mode 100644 index 5dd237f..0000000 --- a/about/security/fips-fedramp.mdx +++ /dev/null @@ -1,40 +0,0 @@ ---- -title: "FIPS 140 and FedRAMP Considerations" -description: "How Pangolin's WireGuard-based tunnels fit into FIPS 140 and FedRAMP-governed environments." ---- - - -This page explains where Pangolin's encryption stands relative to FIPS 140 and FedRAMP requirements. It is not compliance or legal advice - work with your ISSO or compliance team to determine what applies to your specific system boundary. - - -Every Pangolin tunnel - between a site connector and a node, or a client and a site - is a [WireGuard](/development/system-architecture#site-connectors) connection. WireGuard uses a fixed, modern cryptographic suite: Curve25519 for key exchange, ChaCha20-Poly1305 for encryption, and BLAKE2s for hashing. This cryptography is well-reviewed and considered strong, but "strong" and "FIPS 140-validated" are different things - validation refers specifically to a cryptographic module having gone through NIST's Cryptographic Module Validation Program (CMVP), and WireGuard's implementation has not gone through that process. As of this writing, Pangolin does not use a FIPS-validated module for its tunnel encryption. - -## Where Pangolin can fit in a FedRAMP boundary - -Not using a FIPS-validated module for the tunnel itself doesn't automatically disqualify Pangolin from a FedRAMP-authorized environment but it changes how you scope it: - -- **Layer it, don't rely on it alone.** If your system already terminates FIPS-validated TLS or IPsec at its authorization boundary, a Pangolin tunnel can run as an additional encrypted layer inside or alongside that connection rather than being the control that has to satisfy FIPS validation on its own. FedRAMP guidance generally permits non-validated encryption in an inner layer when an outer layer already satisfies the relevant controls (commonly referenced as SC-8(1) and SC-28(1) for transmission and data-at-rest confidentiality). -- **Decide where the boundary sits.** Work with your ISSO to determine, per NIST SP 800-18 and the impact categorization in FIPS 199, whether Pangolin operates inside your system's authorization boundary as a component, or outside it as a supporting service that carries traffic but isn't part of the accredited system itself. -- **Treat it as a component, not the system of record.** Pangolin's [system architecture](/development/system-architecture) - control plane, nodes, connectors, and clients - is documented specifically so it can be mapped cleanly onto a system boundary diagram during this evaluation. - -## Reducing what's outside your control - -If keeping infrastructure inside an already-accredited boundary matters more than convenience, [self-hosting](/self-host/quick-install) is the relevant lever: the control plane and every node can run entirely on infrastructure you already operate and have assessed, rather than depending on Pangolin Cloud's shared infrastructure. [Enterprise Edition](/self-host/enterprise-edition) extends this with clustering, additional identity providers, and centralized logging for environments that need tighter operational control. If your organization needs a compliance package beyond what's documented here, [contact sales](mailto:sales@pangolin.net) to discuss your requirements directly. - - - - Map Pangolin's components onto your system boundary. - - - - Keep the control plane and nodes on infrastructure you already operate. - - - - Clustering, additional identity providers, and centralized logging. - - - - How Pangolin's encryption and authorization model is structured. - - diff --git a/about/security/least-privilege-access.mdx b/about/security/least-privilege-access.mdx deleted file mode 100644 index d3ff1f6..0000000 --- a/about/security/least-privilege-access.mdx +++ /dev/null @@ -1,85 +0,0 @@ ---- -title: "Least-Privilege Access" -description: "Why scoping access down to what's actually needed matters, and how to enforce it with Pangolin's roles, policies, and approvals." ---- - -Every organization eventually asks the same question: does this person, device, or integration really need to reach everything it currently can? Least-privilege access is the practice of answering "no" by default - granting each identity only the specific resources required for its job, and nothing kept around "just in case." It's sometimes called minimal access or need-to-know access, and it's one of the few security practices that pays off whether or not you're ever attacked, because it also limits how much damage a mistake can do. - -## The balance admins actually have to strike - -Scope access too loosely and a single compromised account, leaked token, or careless click can reach far more than it should. Scope it too tightly and people spend their day filing access requests instead of working, while whoever approves those requests turns into a bottleneck for routine tasks. Neither extreme is sustainable. - -The practical goal isn't "as little access as physically possible" - it's a narrow default combined with a fast, auditable path to widen access temporarily when a real need comes up. A contractor who needs staging access for two weeks should get staging access for two weeks, not permanent access "since we'll probably need them again." - -## What tight scoping actually buys you - -If an account is compromised, tight scoping means the attacker inherits only what that account could reach - not the whole organization. It also means fewer people hold standing access to any given system, so there are fewer unexpected changes and a more predictable environment overall. When an incident does happen, scoping by role lets you immediately narrow "who could have done this" to the handful of people who actually had the ability to, instead of auditing everyone. - -There's an ownership benefit too: access mapped to job function makes it obvious who's responsible for what, instead of everyone having a bit of everything. Onboarding and offboarding get simpler, since assigning or removing a role beats auditing an individual's sprawling permission history one entry at a time. And a lot of compliance frameworks specifically expect access to be scoped to job function - this is one of the easier boxes to check when it's built in from the start rather than bolted on afterward. - -## How narrow scope limits real attacks - -Most breaches don't start with an attacker breaking into everything at once - they start with one foothold, then depend on that foothold being able to reach more than it should. Tight scoping blunts this at every step. An attacker trying to climb from a low-privileged account into an administrative one, or hijack a peer's similarly-scoped credentials, simply has less to climb into if roles are narrow to begin with. A single stolen credential goes less far, too: passwords get phished, reused, or leaked in unrelated breaches constantly, and if that credential only unlocks one role's resources, the damage is contained. Malware that hijacks a session or a device inherits whatever that session could reach and nothing more. And insider risk shrinks - a disgruntled or careless employee, whether acting deliberately or by mistake, can only affect the systems their role actually touches. - -## Applying least privilege with Pangolin - -Pangolin gives you the building blocks to enforce this without hand-managing individual permissions: - - - - Group users into [roles](/manage/access-control/create-user#roles) - Operations, Contractor, Support, or any custom role you define - instead of granting access to individuals one at a time. Each [resource](/manage/resources/understanding-resources) specifies which roles can reach it, and a user's effective access is just the union of what their roles allow. Revoke a role and every resource it granted access to disappears with it. - - - - Rather than re-configuring authentication and access settings on every resource individually, define a [resource policy](/manage/resources/public/resource-policies) once and attach it wherever the same baseline should apply. This keeps a large number of resources from silently drifting out of sync with each other. - - - - Layer in [access rules](/manage/access-control/rules) that allow, deny, or require authentication based on URL path, IP, CIDR range, country, or region - useful for exposing only the specific paths a role needs, or keeping traffic from regions you don't operate in out entirely. [Geo-blocking](/manage/geoblocking) and [ASN blocking](/manage/asnblocking) extend this to entire countries or networks, including known VPN, proxy, and datacenter ranges. - - - - [Device approvals](/manage/access-control/approvals) require an administrator to review and approve any new device before it can connect, per role - useful for roles where you don't want valid credentials alone to be sufficient. - - - - [Session length](/manage/access-control/session-length) and [password rotation](/manage/access-control/password-rotation) policies make sure a credential earned once doesn't stay valid indefinitely. Pair this with [MFA](/manage/access-control/mfa) or hardware [security keys](/manage/access-control/security-keys) so a leaked password alone isn't enough to authenticate. - - - - [Admin action logs](/manage/analytics/action) give you an audit trail of configuration changes and administrative actions, so scoping decisions can be revisited based on evidence rather than guesswork. - - - -## A checklist worth revisiting periodically - -- List what each role can currently reach, and confirm it still matches what that role's job requires. -- Strip standing administrative access from roles that only need it occasionally - grant it temporarily instead. -- Give administrators individual accounts rather than sharing one set of credentials. -- Require MFA or security keys, at minimum for any role with elevated access. -- Enforce session length and password rotation so old credentials don't quietly outlive their usefulness. -- Enable device approvals for roles where an unrecognized device connecting should never happen silently. -- Re-audit roles and their resource access on a recurring schedule, not just when something breaks. -- Use rules, geo-blocking, and ASN blocking to shrink who can even attempt to authenticate in the first place. - - - - Group users into roles and control who belongs to each one. - - - - Define authentication and access rules once, reuse them everywhere. - - - - Allow, deny, or require auth based on path, IP, country, or region. - - - - Require admin sign-off before a new device can connect. - - - - - - diff --git a/about/security/wireguard.mdx b/about/security/wireguard.mdx deleted file mode 100644 index 74e0e27..0000000 --- a/about/security/wireguard.mdx +++ /dev/null @@ -1,8 +0,0 @@ ---- -title: "Pangolin Uses WireGuard" -description: "How Pangolin uses WireGuard to create secure, encrypted connections" ---- - -WireGuard is an open source network tunneling protocol for creating encrypted communication channels. It's designed to replace other VPN protocols, such as OpenVPN and IPsec, as a simpler and lighter-weight alternative. The protocol maintains concurrent connections with minimal overhead per session. Independent cryptographers have reviewed the WireGuard protocol, and security auditors have examined the code implementation, identifying and allowing for the correction of minor issues. For more about WireGuard's technical details, review their whitepaper. - -Pangolin builds on top of WireGuard and adds additional components such as NAT traversal, relay fallback, and access control policies. Pangolin's implementation differs from standard WireGuard implementations in several ways. For example, where WireGuard provides encrypted tunnels between endpoints, Pangolin constructs a hub-and-spoke network topology with additional network services and authentication mechanisms. Pangolin uses the open source wireguard-go package running in userspace, which comes with Pangolin's Newt and Olm clients. \ No newline at end of file diff --git a/about/security/zero-trust-networking.mdx b/about/security/zero-trust-networking.mdx deleted file mode 100644 index 98eab8e..0000000 --- a/about/security/zero-trust-networking.mdx +++ /dev/null @@ -1,70 +0,0 @@ ---- -title: "Zero Trust Networking" -description: "Why Pangolin encrypts every connection and authorizes by identity rather than trusting the network it runs on." ---- - -Zero trust networking starts from an uncomfortable assumption: the network your traffic physically travels over cannot be trusted, even the parts you think of as "internal." Access should be decided by who and what is asking, not by which wire or subnet the request happens to arrive on. - -## Why the old assumption stopped holding - -For a long time, network security was designed like a building: a hardened perimeter - the firewall - kept threats out, and everything inside that boundary was implicitly trusted. VPNs extended that same trusted interior out to remote workers. It's sometimes described as a hard shell around a soft interior. - -That model breaks down once you account for how internal networks actually get compromised. Well-documented breaches at major technology companies have shown attackers reaching deep into "internal" infrastructure once they cleared the perimeter. Add unencrypted internal DNS traffic and employees working from coffee shop Wi-Fi, and the idea that anything inside the firewall is automatically safe stops being credible. If the perimeter is the only thing enforcing security, one gap anywhere in it compromises everything behind it. - -## What replaces trusting the wire - -The fix is removing the network itself from the trust decision entirely. Every connection gets encrypted, always, regardless of whether the traffic is "internal" or "external" - there's no privileged segment where encryption gets dropped because it's assumed to be safe. Authorization is based on identity, not location: what a connection is allowed to reach depends on who or what established it, not which subnet it originated from. And compromise gets contained at the peer - if a device is breached, restricting which encrypted peers it's authorized to reach means the attacker can't simply pivot to whatever else happens to share that network segment. - -Even if someone gains physical or logical access to the network itself, all they find is encrypted traffic between authorized peers - nothing to sniff, and no ability to move laterally without valid credentials of their own. - -## How this shows up in Pangolin's architecture - -Pangolin is built around this model rather than having it added on top. Every tunnel is encrypted by default, with no exceptions: site connectors and clients build their tunnels on WireGuard, coordinated by Pangolin's tunnel manager ([Gerbil](/development/system-architecture#nodes)) on the node side and the shared client stack ([Olm](/development/system-architecture#shared-client-stack-olm)) on the device side. There's no unencrypted "trusted internal" path anywhere in the data plane. - -Site connectors are also deny-by-default proxies - installing a [site](/manage/sites/understanding-sites) doesn't expose anything on that network. Nothing is reachable until you explicitly define a [resource](/manage/resources/understanding-resources) and grant a role access to it; the network being reachable is never itself the authorization. Authentication happens per request, not per network: public resources are enforced through forward-auth middleware ([Badger](/development/system-architecture#nodes)) sitting in front of every request, and private resources are only reachable by clients that already hold a valid, role-scoped tunnel - being on the same LAN as a site connector grants nothing on its own. - -Access follows the user rather than the connection point. [Roles](/manage/access-control/create-user#roles) determine which resources a client is authorized to reach, and that access control list travels with the identity whether they're connecting from the office, home, or a coffee shop. - -## Rolling this out without a flag day - -You don't need to tear out your existing perimeter to start moving toward this model - it's meant to be adopted incrementally, service by service: - - - - Stand up a [site](/manage/sites/install-site) alongside your existing network and turn a single high-value internal service into a [private resource](/manage/resources/understanding-resources#private-resource-types). Traffic to it is now encrypted end-to-end and gated by role, independent of whatever perimeter controls already exist. - - - - Confirm that only the intended roles can reach the new resource, and that removing a role actually removes access - not just that the tunnel works. - - - - Move the rest of the hosts behind that network into resources one at a time. Once everything on that subnet is reachable exclusively through Pangolin, the broader network-level trust it used to rely on (a VPN route, a wide firewall allowlist) is no longer load-bearing. - - - - Remove the legacy access route for that subnet, then repeat the process for the next site or location. - - - -## What you still have to trust - -"Zero trust" doesn't mean trusting nothing - it means narrowing what you trust down to a small, deliberate set of components: your identity provider, and the policy engine that decides who's authorized to reach what. In Pangolin, that's the [control plane](/development/system-architecture#control-plane) - the server that authenticates users, evaluates roles against resources, and pushes the resulting access decisions out to every node, site, and client. Everything else - the physical network, the datacenter, the office Wi-Fi - is treated as untrusted by design, which is precisely the point. - - - - The product-level tour of sites, resources, and clients. - - - - A technical look at the control plane, nodes, connectors, and clients. - - - - How site connectors expose resources without opening inbound ports. - - - - Scoping who's authorized to reach what once identity is the boundary. - - diff --git a/app/(docs)/[[...slug]]/page.tsx b/app/(docs)/[[...slug]]/page.tsx new file mode 100644 index 0000000..97c5956 --- /dev/null +++ b/app/(docs)/[[...slug]]/page.tsx @@ -0,0 +1,86 @@ +import { source } from '@/lib/source'; +import { + DocsBody, + DocsDescription, + DocsPage, + DocsTitle, + MarkdownCopyButton, + ViewOptionsPopover, +} from 'fumadocs-ui/layouts/notebook/page'; +import { notFound } from 'next/navigation'; +import { getMDXComponents } from '@/components/mdx'; +import type { Metadata } from 'next'; +import { createRelativeLink } from 'fumadocs-ui/mdx'; +import { getPageImageUrl, getPageMarkdownUrl, gitConfig, siteUrl } from '@/lib/shared'; +import { AskAIAboutPage } from '@/components/ai/ask-page'; +import { SiteFooter } from '@/components/site-footer'; +import { AskBar } from '@/components/ai/ask-bar'; + +export default async function Page(props: PageProps<'/[[...slug]]'>) { + const params = await props.params; + const page = source.getPage(params.slug); + if (!page) notFound(); + + const MDX = page.data.body; + const markdownUrl = getPageMarkdownUrl(page).url; + + return ( + + + + + ), + }} + > + {page.data.title} + {page.data.description} +
+ + + +
+ + + +
+ ); +} + +export async function generateStaticParams() { + return source.generateParams(); +} + +export async function generateMetadata(props: PageProps<'/[[...slug]]'>): Promise { + const params = await props.params; + const page = source.getPage(params.slug); + if (!page) notFound(); + + const isHome = page.slugs.length === 0; + return { + title: isHome ? { absolute: `${page.data.title} - Pangolin Docs` } : page.data.title, + description: page.data.description, + alternates: { + canonical: page.url, + types: { 'text/markdown': getPageMarkdownUrl(page).url }, + }, + openGraph: { + images: isHome ? '/images/home-social-graph.png' : getPageImageUrl(page).url, + }, + }; +} diff --git a/app/(docs)/layout.tsx b/app/(docs)/layout.tsx new file mode 100644 index 0000000..f710e18 --- /dev/null +++ b/app/(docs)/layout.tsx @@ -0,0 +1,65 @@ +import { DocsLayout } from 'fumadocs-ui/layouts/notebook'; +import type { LayoutTab } from 'fumadocs-ui/layouts/shared'; +import { Banner } from 'fumadocs-ui/components/banner'; +import Link from 'fumadocs-core/link'; +import { baseOptions } from '@/lib/layout.shared'; +import { getNavigationTree, navTabs } from '@/lib/navigation'; +import { banner } from '@/lib/shared'; +import { source } from '@/lib/source'; +import type { CSSProperties } from 'react'; +import { AISearch, AISearchPanel } from '@/components/ai/search'; +import { headerSearchSlot } from '@/components/header-search'; +import { SidebarAnchors } from '@/components/sidebar-anchors'; + +const tabs: LayoutTab[] = [ + // every docs page counts as the "Documentation" tab + { title: 'Documentation', url: '/', urls: new Set(source.getPages().map((page) => page.url)) }, + ...navTabs.map((tab) => ({ + title: tab.label, + url: tab.href, + props: { target: '_blank', rel: 'noreferrer noopener' }, + })), +]; + +/** Mintlify-style grid, defined as `--pg-grid` in app/global.css (responsive) */ +const containerStyle = { gridTemplate: 'var(--pg-grid)' } as CSSProperties; + +export default function Layout({ children }: LayoutProps<'/'>) { + return ( + <> + +

+ AI Gateway is now available + + : identity-aware access to any AI provider, eliminate API keys, and tunnel to + self-hosted models + + .{' '} + + {banner.link.label} + +

+
+ {/* chat state wraps the whole layout so the header "Ask AI" button can open it */} + + + + {children} + + + + ); +} diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts new file mode 100644 index 0000000..a84a442 --- /dev/null +++ b/app/api/chat/route.ts @@ -0,0 +1,80 @@ +import { + convertToModelMessages, + createUIMessageStreamResponse, + stepCountIs, + streamText, + toUIMessageStream, + type SystemModelMessage, +} from 'ai'; +import type { ChatUIMessage } from '@/components/ai/search'; +import { AIConfigError, getModel } from '@/lib/ai/model'; +import { getSystemPrompt } from '@/lib/ai/prompt'; +import { checkRateLimit, clientKey } from '@/lib/ai/rate-limit'; +import { tools } from '@/lib/ai/tools'; + +export const maxDuration = 120; + +const MAX_MESSAGES = 30; +const MAX_MESSAGE_CHARS = 8_000; + +function json(status: number, message: string, headers?: HeadersInit) { + return Response.json({ error: message }, { status, headers }); +} + +export async function POST(req: Request) { + const limit = checkRateLimit(clientKey(req)); + if (!limit.ok) { + return json(429, `Too many questions, try again in ${limit.retryAfter}s.`, { + 'Retry-After': String(limit.retryAfter), + }); + } + + let config; + try { + config = getModel(); + } catch (e) { + if (e instanceof AIConfigError) return json(503, e.message); + throw e; + } + + const body = (await req.json().catch(() => null)) as { messages?: ChatUIMessage[] } | null; + const messages = (body?.messages ?? []).slice(-MAX_MESSAGES); + const tooLong = messages.some((m) => + m.parts.some((p) => p.type === 'text' && p.text.length > MAX_MESSAGE_CHARS), + ); + if (messages.length === 0 || tooLong) return json(400, 'Invalid or too long message.'); + + const instructions: SystemModelMessage = { + role: 'system', + content: getSystemPrompt(), + // the system prompt is large and identical across requests + providerOptions: { anthropic: { cacheControl: { type: 'ephemeral' } } }, + }; + + const result = streamText({ + model: config.model, + stopWhen: stepCountIs(8), + tools, + toolChoice: 'auto', + instructions, + messages: await convertToModelMessages(messages, { + convertDataPart(part) { + if (part.type === 'data-client') + return { + type: 'text', + text: `[Client Context: ${JSON.stringify(part.data)}]`, + }; + }, + }), + onError({ error }) { + console.error('[api/chat]', error); + }, + }); + + return createUIMessageStreamResponse({ + stream: toUIMessageStream({ + stream: result.stream, + onError: () => 'The assistant hit an error. Please try again.', + }), + }); +} diff --git a/app/api/search/route.ts b/app/api/search/route.ts new file mode 100644 index 0000000..b702a76 --- /dev/null +++ b/app/api/search/route.ts @@ -0,0 +1,3 @@ +import { searchServer } from '@/lib/search'; + +export const { GET } = searchServer; diff --git a/app/global.css b/app/global.css new file mode 100644 index 0000000..67a4ab4 --- /dev/null +++ b/app/global.css @@ -0,0 +1,1023 @@ +@import 'tailwindcss'; +@import 'fumadocs-ui/css/neutral.css'; +@import 'fumadocs-ui/css/preset.css'; + +/* -------------------------------------------------------------------------- */ +/* Pangolin palette (ported from the Mintlify docs.json + style.css) */ +/* -------------------------------------------------------------------------- */ + +@theme { + --font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif; + + --color-pg-beige: #faf9f2; /* page background */ + --color-pg-sand: #f2f0e7; /* nav, sidebar, code, cards */ + --color-pg-line: #bbbbbb; /* hairlines, same as pangolin.net grey-light-8 */ + --color-pg-ink: #202020; /* text + primary */ + --color-pg-orange: #f36117; /* brand accent (dark primary in Mintlify) */ + + --color-fd-background: #faf9f2; + --color-fd-foreground: #202020; + --color-fd-muted: #f2f0e7; + --color-fd-muted-foreground: #6b6a63; + --color-fd-popover: #faf9f2; + --color-fd-popover-foreground: #202020; + --color-fd-card: #f2f0e7; + --color-fd-card-foreground: #202020; + --color-fd-border: #bbbbbb; + --color-fd-primary: #202020; + --color-fd-primary-foreground: #faf9f2; + --color-fd-secondary: #f2f0e7; + --color-fd-secondary-foreground: #202020; + --color-fd-accent: #e8e7e5; + --color-fd-accent-foreground: #202020; + --color-fd-ring: #bbbbbb; + --color-fd-overlay: rgba(32, 32, 32, 0.2); + + --pg-code-bg: #f2f0e7; + --pg-code-border: #bbbbbb; +} + +/* only used when `enableDarkMode` is on in lib/shared.ts */ +.dark { + --color-fd-background: #161614; + --color-fd-foreground: #ecebe6; + --color-fd-muted: #1f1e1b; + --color-fd-muted-foreground: #a3a199; + --color-fd-popover: #1b1a18; + --color-fd-popover-foreground: #ecebe6; + --color-fd-card: #1f1e1b; + --color-fd-card-foreground: #ecebe6; + --color-fd-border: #2d2c28; + --color-fd-primary: #f36117; + --color-fd-primary-foreground: #161614; + --color-fd-secondary: #1f1e1b; + --color-fd-secondary-foreground: #ecebe6; + --color-fd-accent: #2d2c28; + --color-fd-accent-foreground: #ecebe6; + --color-fd-ring: #55534c; + + --pg-code-bg: #1f1e1b; + --pg-code-border: #3a3934; +} + +html { + scrollbar-gutter: stable; +} + +html > body[data-scroll-locked] { + margin-right: 0px !important; + --removed-body-scroll-bar-size: 0px !important; +} + +body { + background-color: var(--color-fd-background); + color: var(--color-fd-foreground); +} + +/* pangolin.net button typeface */ +@font-face { + font-family: 'Die Grotesk B'; + src: url('/fonts/die-grotesk-b-regular.woff2') format('woff2'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Die Grotesk B'; + src: url('/fonts/die-grotesk-b-medium.woff2') format('woff2'); + font-weight: 500; + font-style: normal; + font-display: swap; +} + +/* + * Hairlines like pangolin.net (0.5px, #bbb). Unlayered, so these win over the 1px + * Tailwind border utilities used by Fumadocs; 2px accents (active tab) are untouched. + */ +.border { + border-width: 0.5px; +} +.border-t { + border-top-width: 0.5px; +} +.border-b { + border-bottom-width: 0.5px; +} +.border-l, +.border-s { + border-inline-start-width: 0.5px; +} +.border-r, +.border-e { + border-inline-end-width: 0.5px; +} +.border-x { + border-inline-width: 0.5px; +} +.border-y { + border-block-width: 0.5px; +} + +/* -------------------------------------------------------------------------- */ +/* Chrome: navbar, sidebar, banner */ +/* -------------------------------------------------------------------------- */ + +/* + * Layout grid (used by app/(docs)/layout.tsx). Desktop: the sidebar is pinned to the + * left edge and content + TOC are centered in the remaining space, max 70rem like the + * old Mintlify site; the last column holds the AI chat panel when it is open. + */ +#nd-notebook-layout { + --pg-content-width: 70rem; + --pg-grid: 'header header' 'sidebar toc-popover' 'sidebar main' 1fr / var(--fd-sidebar-col) + minmax(0, 1fr); +} + +@media (min-width: 1024px) { + #nd-notebook-layout { + /* width taken by the AI chat panel when it is open */ + --pg-panel: 0px; + /* equal gutters that center content + TOC; they collapse to 0 on narrow screens */ + --pg-gutter: max( + 0px, + calc((100% - var(--fd-sidebar-col) - var(--pg-content-width) - var(--pg-panel)) / 2) + ); + --pg-grid: 'header header header header header' 'sidebar . toc-popover toc-popover .' + 'sidebar . main toc .' 1fr / var(--fd-sidebar-col) var(--pg-gutter) minmax(0, 1fr) + var(--fd-toc-width) calc(var(--pg-gutter) + var(--pg-panel)); + } + + /* set from components/ai/search.tsx while the chat panel is open */ + #nd-notebook-layout[data-ai-open] { + --pg-panel: 400px; + } + + /* slide the content along with the panel's open / close animation */ + #nd-notebook-layout { + transition: grid-template-columns 200ms ease; + } +} + +@media (min-width: 1536px) { + #nd-notebook-layout[data-ai-open] { + --pg-panel: 460px; + } +} + +#nd-subnav { + background-color: color-mix(in srgb, var(--color-fd-muted) 92%, transparent); +} + +#nd-subnav [data-header-body], +#nd-subnav [data-header-tabs] { + border-color: var(--color-fd-border); +} + +/* tab row under the navbar */ +#nd-subnav [data-header-tabs] a { + font-weight: 500; +} + +#nd-sidebar { + background-color: var(--color-fd-muted); + border-inline-end: 0.5px solid var(--color-fd-border); +} + +/* separators = Mintlify top-level group headings */ +#nd-sidebar p[data-separator], +#nd-sidebar [data-separator] { + color: var(--color-fd-foreground); + font-weight: 600; +} + +/* + * First group heading ("About") under the anchors: Fumadocs only drops its top margin + * when it is the very first element, but hidden mobile menu links come before it. Trim + * it so anchors -> "About" matches the gap between groups (~26px incl. viewport padding). + */ +#nd-sidebar p.mt-6:first-of-type { + margin-top: 0.625rem; +} + +/* Mintlify-style sidebar anchors (Discord, Slack): icon tile + medium label */ +.pg-anchor { + display: flex; + align-items: center; + gap: 0.75rem; + border-radius: 0.5rem; + padding: 0.25rem 0.5rem; + font-size: 0.875rem; + font-weight: 500; + color: var(--color-fd-muted-foreground); + transition: color 150ms; +} + +.pg-anchor:hover { + color: var(--color-fd-foreground); +} + +.pg-anchor-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + flex-shrink: 0; + border: 0.5px solid var(--color-fd-border); + border-radius: 0.375rem; + background-color: var(--color-fd-background); + color: var(--color-fd-muted-foreground); + transition: + color 150ms, + border-color 150ms, + background-color 150ms; +} + +.pg-anchor-icon svg { + width: 0.875rem; + height: 0.875rem; +} + +.pg-anchor:hover .pg-anchor-icon { + border-color: var(--color-fd-foreground); + background-color: var(--color-fd-foreground); + color: var(--color-fd-background); +} + +.pg-banner { + background-color: var(--color-pg-ink); + color: #faf9f2; + font-size: 0.875rem; +} + +.pg-banner button { + color: #faf9f2; +} + +/* header buttons: pangolin.net `btn` / `btnSecondary` + `btnSm` */ +.pg-btn { + display: inline-block; + border-radius: 9999px; + border: 0; + background-color: #202020; + color: #faf9f2; + font-family: 'Die Grotesk B', var(--font-sans); + font-size: 0.875rem; + font-weight: 500; + line-height: 1.4; + padding: 0.75rem 1.25rem; + white-space: nowrap; + text-decoration: none; + transition: transform 300ms; +} + +.pg-btn:hover { + transform: scale(1.025); +} + +.pg-btn-secondary { + border: 1px solid #bbbbbb; + background-color: #faf9f2; + color: #202020; + /* 1px border: keep the same outer size as the primary button */ + padding: calc(0.75rem - 1px) calc(1.25rem - 1px); +} + +.dark .pg-btn { + background-color: #ecebe6; + color: #161614; +} + +.dark .pg-btn-secondary { + border-color: #3a3934; + background-color: transparent; + color: #ecebe6; +} + +/* "Ask AI" button to the right of the header search (Mintlify style) */ +.pg-ask-ai-btn { + display: inline-flex; + flex-shrink: 0; + align-items: center; + gap: 0.4rem; + height: 2.25rem; + padding: 0 0.85rem; + border: 0.5px solid var(--color-fd-border); + border-radius: 0.75rem; + background-color: color-mix(in srgb, var(--color-fd-secondary) 50%, transparent); + color: var(--color-fd-muted-foreground); + font-size: 0.875rem; + white-space: nowrap; + transition: + background-color 150ms, + color 150ms; +} + +.pg-ask-ai-btn:hover, +.pg-ask-ai-btn[data-state='open'] { + background-color: var(--color-fd-accent); + color: var(--color-fd-accent-foreground); +} + +.pg-ask-ai-btn svg { + width: 1rem; + height: 1rem; + color: var(--color-pg-orange); +} + +/* logo: fixed height, width from the image's aspect ratio (never stretched) */ +.pg-logo { + display: block; + height: 1.5rem; + width: auto; + max-width: none; + flex-shrink: 0; + object-fit: contain; +} + +.pg-logo.hidden { + display: none; +} + +.dark .pg-logo.dark\:block { + display: block; +} + +.dark .pg-logo.dark\:hidden { + display: none; +} + +/* ---------- AI chat input (Mintlify style) ---------- */ +.pg-chat-input { + border: 0.5px solid var(--color-fd-border); + border-radius: 1rem; + background-color: var(--color-fd-background); + box-shadow: 0 1px 2px rgba(32, 32, 32, 0.04); + transition: + border-color 150ms, + box-shadow 150ms; +} + +.pg-chat-input:focus-within { + border-color: #8d8d8d; + box-shadow: 0 4px 16px rgba(32, 32, 32, 0.08); +} + +.pg-chat-send { + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 1.9rem; + height: 1.9rem; + border-radius: 9999px; + background-color: var(--color-fd-primary); + color: var(--color-fd-primary-foreground); + transition: + opacity 150ms, + background-color 150ms; +} + +.pg-chat-send:disabled { + background-color: #e8e7e5; + color: #8d8d8d; + cursor: default; +} + +.dark .pg-chat-send:disabled { + background-color: #2d2c28; +} + +/* sticky bar at the bottom of each page */ +.pg-askbar-wrap { + position: sticky; + bottom: 1.25rem; + z-index: 20; + display: flex; + justify-content: center; + margin-top: 1.5rem; + pointer-events: none; + transition: + opacity 200ms, + translate 200ms; +} + +.pg-askbar-hidden { + opacity: 0; + translate: 0 0.75rem; +} + +.pg-askbar { + pointer-events: auto; + display: flex; + align-items: center; + gap: 0.5rem; + width: min(100%, 28rem); + padding: 0.4rem 0.4rem 0.4rem 1rem; + box-shadow: 0 8px 28px rgba(32, 32, 32, 0.1); +} + +.pg-askbar-hidden .pg-askbar { + pointer-events: none; +} + +.pg-askbar:focus-within { + box-shadow: 0 8px 28px rgba(32, 32, 32, 0.14); +} + +.pg-askbar input { + flex: 1; + min-width: 0; + background: transparent; + font-size: 0.9rem; + outline: none; +} + +/* iOS zooms the page when a focused field is under 16px. Search uses text-lg, so it doesn't. */ +@media (hover: none) and (pointer: coarse) { + .pg-chat-field, + .pg-askbar input { + font-size: 16px; + } +} + +.pg-askbar input::placeholder { + color: #8d8d8d; +} + +.pg-askbar-kbd { + border: 0.5px solid var(--color-fd-border); + border-radius: 0.375rem; + padding: 0.05rem 0.35rem; + font-family: inherit; + font-size: 0.7rem; + color: var(--color-fd-muted-foreground); +} + +@media (max-width: 767px) { + .pg-askbar-kbd { + display: none; + } +} + +/* -------------------------------------------------------------------------- */ +/* Content */ +/* -------------------------------------------------------------------------- */ + +/* inline code: match pangolin.net/news */ +.prose :where(code):not(:where(pre code, .not-prose code)) { + background-color: var(--pg-code-bg); + border: 0.5px solid var(--pg-code-border); + border-radius: 4px; + color: var(--color-fd-foreground); + font-size: 0.875em; + font-weight: 400; + padding: 0.125rem 0.35rem; +} + +/* code blocks */ +.prose figure.shiki, +figure.shiki { + background-color: var(--pg-code-bg); + border-color: var(--color-fd-border); + box-shadow: none; +} + +figure.shiki pre, +figure.shiki .shiki { + background-color: transparent !important; +} + +figure.shiki [data-line].highlighted, +figure.shiki .line.highlighted { + background-color: rgba(243, 97, 23, 0.1); + box-shadow: inset 2px 0 0 var(--color-pg-orange); +} + +/* tables */ +.prose table { + font-size: 0.875rem; +} + +/* ---------- callouts: beige with a coloured bar on the left ---------- */ +.pg-callout { + --pg-callout-bar: #525252; + position: relative; + display: flex; + gap: 0.75rem; + margin-block: 1.25rem; + padding: 0.9rem 1rem 0.9rem 1.5rem; + border-radius: 0.75rem; + background-color: var(--pg-code-bg); + color: var(--color-fd-foreground); + font-size: 0.9rem; + overflow: hidden; +} + +.pg-callout::before { + content: ''; + position: absolute; + inset-block: 0; + left: 0; + width: 8px; + background-color: var(--pg-callout-bar); +} + +.pg-callout[data-type='note'] { + --pg-callout-bar: #2563eb; +} +.pg-callout[data-type='info'] { + --pg-callout-bar: #525252; +} +.pg-callout[data-type='tip'] { + --pg-callout-bar: #16a34a; +} +.pg-callout[data-type='check'] { + --pg-callout-bar: #16a34a; +} +.pg-callout[data-type='warning'] { + --pg-callout-bar: #ca8a04; +} +.pg-callout[data-type='danger'] { + --pg-callout-bar: #dc2626; +} + +.pg-callout-icon { + flex-shrink: 0; + margin-top: 0.15rem; + color: var(--pg-callout-bar); +} + +.pg-callout-icon svg { + width: 1rem; + height: 1rem; +} + +.pg-callout-body { + min-width: 0; + flex: 1; + font-size: 0.9rem; +} + +.pg-callout-body > :first-child { + margin-top: 0; +} +.pg-callout-body > :last-child { + margin-bottom: 0; +} + +/* ---------- cards ---------- */ +.pg-card-group { + display: grid; + gap: 1rem; + margin-block: 1.25rem; + grid-template-columns: repeat(1, minmax(0, 1fr)); +} + +@media (min-width: 640px) { + .pg-card-group { + grid-template-columns: repeat(min(var(--pg-cols, 2), 2), minmax(0, 1fr)); + } +} + +@media (min-width: 1024px) { + .pg-card-group { + grid-template-columns: repeat(var(--pg-cols, 2), minmax(0, 1fr)); + } +} + +.pg-card { + display: block; + overflow: hidden; + border-radius: 0.75rem; + background-color: var(--color-fd-card); + color: var(--color-fd-card-foreground); + margin-block: 1rem; + transition: background-color 150ms; +} + +.pg-card-group > .pg-card { + margin-block: 0; +} + +.pg-card-link { + position: relative; +} + +.pg-card-link:hover { + background-color: var(--color-fd-accent); +} + +.pg-card-overlay { + position: absolute; + inset: 0; + z-index: 1; + border-radius: inherit; +} + +.pg-card-overlay:focus-visible { + outline: 2px solid var(--color-fd-ring); + outline-offset: 2px; +} + +/* links inside a linked card stay clickable above the overlay */ +.pg-card-link .pg-card-content a { + position: relative; + z-index: 2; +} + +.pg-card-img { + width: 100%; + object-fit: cover; +} + +.pg-card-inner { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 1.25rem; +} + +.pg-card-horizontal { + flex-direction: row; + align-items: center; +} + +.pg-card-icon { + display: inline-flex; + color: var(--color-fd-foreground); +} + +.pg-card-icon svg { + width: 1.25rem; + height: 1.25rem; +} + +.pg-card-title { + display: flex; + align-items: center; + gap: 0.35rem; + font-weight: 600; + font-size: 0.95rem; +} + +.pg-card-arrow { + width: 0.9rem; + height: 0.9rem; + color: var(--color-fd-muted-foreground); +} + +.pg-card-content { + margin-top: 0.25rem; + color: var(--color-fd-muted-foreground); + font-size: 0.875rem; +} + +.pg-card-content > :first-child { + margin-top: 0; +} +.pg-card-content > :last-child { + margin-bottom: 0; +} + +.pg-card-cta { + display: inline-flex; + align-items: center; + gap: 0.2rem; + margin-top: 0.75rem; + font-size: 0.875rem; + font-weight: 500; +} + +/* ---------- steps ---------- */ +.pg-steps { + counter-reset: pg-step; + margin-block: 1.5rem; +} + +.pg-step { + counter-increment: pg-step; + position: relative; + padding-left: 2.75rem; + padding-bottom: 1.75rem; +} + +.pg-step::after { + content: ''; + position: absolute; + left: 0.875rem; + top: 2.25rem; + bottom: 0.25rem; + width: 0.5px; + background-color: var(--color-fd-border); +} + +.pg-step:last-child { + padding-bottom: 0.25rem; +} + +.pg-step:last-child::after { + display: none; +} + +.pg-step-marker { + position: absolute; + left: 0; + top: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + border-radius: 9999px; + background-color: var(--color-fd-muted); + font-size: 0.8rem; + font-weight: 600; +} + +.pg-step-marker:empty::before { + content: counter(pg-step); +} + +.pg-step-title { + margin: 0 !important; + padding-top: 0.15rem; + font-weight: 600; + line-height: 1.5rem; +} + +.pg-step-content > :first-child { + margin-top: 0.5rem; +} +.pg-step-content > :last-child { + margin-bottom: 0; +} + +/* ---------- accordions & expandables ---------- */ +.pg-accordion-group { + margin-block: 1.25rem; + border: 0.5px solid var(--color-fd-border); + border-radius: 0.75rem; + overflow: hidden; +} + +.pg-accordion { + border: 0.5px solid var(--color-fd-border); + border-radius: 0.75rem; + margin-block: 0.75rem; + background-color: var(--color-fd-background); +} + +.pg-accordion-group > .pg-accordion { + margin: 0; + border: 0; + border-radius: 0; +} + +.pg-accordion-group > .pg-accordion + .pg-accordion { + border-top: 0.5px solid var(--color-fd-border); +} + +.pg-accordion > summary, +.pg-expandable > summary { + display: flex; + align-items: center; + gap: 0.6rem; + cursor: pointer; + list-style: none; + user-select: none; +} + +.pg-accordion > summary::-webkit-details-marker, +.pg-expandable > summary::-webkit-details-marker { + display: none; +} + +.pg-accordion > summary { + padding: 0.85rem 1rem; +} + +.pg-accordion > summary:hover { + background-color: var(--color-fd-muted); +} + +.pg-accordion-chevron { + width: 1rem; + height: 1rem; + flex-shrink: 0; + color: var(--color-fd-muted-foreground); + transition: transform 150ms; +} + +details[open] > summary > .pg-accordion-chevron { + transform: rotate(90deg); +} + +.pg-accordion-content { + padding: 0 1rem 1rem 2.6rem; +} + +.pg-accordion-content > :first-child { + margin-top: 0; +} +.pg-accordion-content > :last-child { + margin-bottom: 0; +} + +.pg-expandable { + margin-top: 0.75rem; + border: 0.5px solid var(--color-fd-border); + border-radius: 0.6rem; +} + +.pg-expandable > summary { + padding: 0.4rem 0.75rem; + font-size: 0.8rem; + color: var(--color-fd-muted-foreground); +} + +.pg-expandable > summary:hover { + color: var(--color-fd-foreground); +} + +.pg-expandable .pg-expandable-open { + display: none; +} +.pg-expandable[open] > summary .pg-expandable-open { + display: inline; +} +.pg-expandable[open] > summary .pg-expandable-closed { + display: none; +} + +.pg-expandable-content { + padding: 0 0.9rem 0.25rem; + border-top: 0.5px solid var(--color-fd-border); +} + +.pg-expandable-content > .pg-field:last-child { + border-bottom: 0; +} + +/* ---------- response fields ---------- */ +.pg-field { + padding-block: 1rem; + border-bottom: 0.5px solid var(--color-fd-border); +} + +.pg-field-header { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + font-size: 0.8rem; +} + +.pg-field-name { + font-family: var(--font-mono, ui-monospace, monospace); + font-weight: 600; + font-size: 0.875rem; + color: var(--color-pg-orange); + background: none !important; + border: 0 !important; + padding: 0 !important; +} + +.pg-field-type, +.pg-field-pill { + border-radius: 0.375rem; + background-color: var(--color-fd-muted); + color: var(--color-fd-muted-foreground); + padding: 0.1rem 0.45rem; + font-size: 0.75rem; +} + +.pg-field-pill code { + background: none !important; + border: 0 !important; + padding: 0 !important; + font-size: 0.75rem !important; +} + +.pg-field-required { + color: #dc2626; + font-weight: 500; + font-size: 0.75rem; +} + +.pg-field-deprecated { + color: #ca8a04; + font-weight: 500; + font-size: 0.75rem; +} + +.pg-field-body { + margin-top: 0.5rem; + font-size: 0.9rem; +} + +.pg-field-body > :first-child { + margin-top: 0; +} +.pg-field-body > :last-child { + margin-bottom: 0; +} + +/* ---------- frames ---------- */ +.pg-frame { + margin-block: 1.5rem; +} + +.pg-frame-inner { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + padding: 0.45rem; + border-radius: 0.75rem; + background-color: var(--pg-code-bg); + border: 0.5px solid var(--color-fd-border); +} + +.pg-frame-inner img, +.pg-frame-inner video, +.pg-frame-inner iframe { + border-radius: 0.4rem; + max-width: 100%; + height: auto; + margin: 0; +} + +.pg-frame-inner video, +.pg-frame-inner iframe { + align-self: stretch; + width: 100%; + aspect-ratio: 16 / 9; +} + +.pg-frame figcaption, +.pg-frame-hint { + margin: 0; + padding-inline: 0.35rem; + text-align: center; + font-size: 0.85rem; + line-height: 1.4; + color: var(--color-fd-muted-foreground); +} + +.prose img { + border-radius: 0.6rem; +} + +/* ---------- changelog updates ---------- */ +.pg-update { + display: grid; + gap: 1rem; + padding-block: 1.5rem; + border-bottom: 0.5px solid var(--color-fd-border); +} + +@media (min-width: 768px) { + .pg-update { + grid-template-columns: 10rem 1fr; + } +} + +/* ---------- footer ---------- */ +.pg-footer { + display: flex; + flex-direction: column; + gap: 1rem; + margin-top: 3rem; + padding-top: 1.5rem; + border-top: 0.5px solid var(--color-fd-border); +} + +.pg-download { + display: inline-flex; + align-items: center; + gap: 0.4rem; + height: 2.5rem; + padding: 0 0.75rem; + border-radius: 0.5rem; + border: 1px solid var(--color-fd-border); + background-color: #fff; + color: var(--color-fd-muted-foreground); + font-size: 0.875rem; + line-height: 1; + white-space: nowrap; + text-decoration: none; + transition: + color 150ms, + background-color 150ms; +} + +.pg-download:hover { + background-color: var(--color-fd-accent); + color: var(--color-fd-foreground); +} + +.dark .pg-download { + background-color: var(--color-fd-card); +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..f9d7eb9 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,43 @@ +import { RootProvider } from 'fumadocs-ui/provider/next'; +import type { Metadata, Viewport } from 'next'; +import { Inter } from 'next/font/google'; +import { Analytics } from '@/components/analytics'; +import { appName, enableDarkMode, siteDescription, siteUrl } from '@/lib/shared'; +import './global.css'; + +const inter = Inter({ + subsets: ['latin'], + variable: '--font-inter', +}); + +export const metadata: Metadata = { + metadataBase: new URL(siteUrl), + title: { template: `%s - ${appName}`, default: appName }, + description: siteDescription, + icons: { icon: '/favicon.png' }, + openGraph: { siteName: appName, images: '/images/home-social-graph.png' }, + twitter: { card: 'summary_large_image' }, +}; + +export const viewport: Viewport = { + themeColor: '#FAF9F2', +}; + +export default function Layout({ children }: LayoutProps<'/'>) { + return ( + + + + {children} + + + + + ); +} diff --git a/app/llms-full.txt/route.ts b/app/llms-full.txt/route.ts new file mode 100644 index 0000000..0bf85a8 --- /dev/null +++ b/app/llms-full.txt/route.ts @@ -0,0 +1,20 @@ +import { getPageText, source } from '@/lib/source'; +import { getOrderedPagePaths } from '@/lib/navigation'; + +export const revalidate = false; + +export async function GET() { + const pages = source.getPages(); + const byPath = new Map(pages.map((page) => [page.slugs.join('/') || 'index', page])); + + // sidebar order first, then everything that isn't listed in the sidebar + const ordered = getOrderedPagePaths() + .map((path) => byPath.get(path)) + .filter((page) => page !== undefined); + const rest = pages.filter((page) => !ordered.includes(page)); + + const texts = await Promise.all([...ordered, ...rest].map(getPageText)); + return new Response(texts.join('\n\n---\n\n'), { + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, + }); +} diff --git a/app/llms.mdx/[[...slug]]/route.ts b/app/llms.mdx/[[...slug]]/route.ts new file mode 100644 index 0000000..aa9b6d8 --- /dev/null +++ b/app/llms.mdx/[[...slug]]/route.ts @@ -0,0 +1,24 @@ +import { docsLlms, source } from '@/lib/source'; +import { getPageMarkdownUrl } from '@/lib/shared'; +import { notFound } from 'next/navigation'; + +export const revalidate = false; + +export async function GET(_req: Request, { params }: RouteContext<'/llms.mdx/[[...slug]]'>) { + const { slug } = await params; + // last segment is always `content.md` + const page = source.getPage(slug?.slice(0, -1)); + if (!page) notFound(); + + return new Response(await docsLlms.page(page), { + headers: { + 'Content-Type': 'text/markdown; charset=utf-8', + }, + }); +} + +export function generateStaticParams() { + return source.getPages().map((page) => ({ + slug: getPageMarkdownUrl(page).segments, + })); +} diff --git a/app/llms.txt/route.ts b/app/llms.txt/route.ts new file mode 100644 index 0000000..ae5ca7b --- /dev/null +++ b/app/llms.txt/route.ts @@ -0,0 +1,62 @@ +import { source } from '@/lib/source'; +import { getNavigationTree } from '@/lib/navigation'; +import { appName, siteDescription, siteUrl } from '@/lib/shared'; +import type * as PageTree from 'fumadocs-core/page-tree'; + +export const revalidate = false; + +/** https://llmstxt.org — follows the sidebar structure, plus pages not listed in it */ +export async function GET() { + const tree = getNavigationTree(); + const lines: string[] = [ + `# ${appName}`, + '', + `> ${siteDescription}`, + '', + `The full documentation as a single file is available at ${siteUrl}/llms-full.txt. Append \`.md\` to any page URL to get its Markdown source.`, + '', + ]; + const listed = new Set(); + + function descriptionOf(url: string) { + const page = source.getPages().find((p) => p.url === url); + return page?.data.description; + } + + function item(node: PageTree.Item, depth: number) { + listed.add(node.url); + const description = descriptionOf(node.url); + lines.push( + `${' '.repeat(depth)}- [${String(node.name)}](${siteUrl}${node.url}.md)${description ? `: ${description}` : ''}`, + ); + } + + function walk(nodes: PageTree.Node[], depth: number) { + for (const node of nodes) { + if (node.type === 'separator') { + lines.push('', `## ${String(node.name)}`, ''); + } else if (node.type === 'page') { + item(node, depth); + } else { + lines.push(`${' '.repeat(depth)}- ${String(node.name)}`); + if (node.index) item(node.index, depth + 1); + walk(node.children, depth + 1); + } + } + } + walk(tree.children, 0); + + const optional = source.getPages().filter((page) => !listed.has(page.url)); + if (optional.length > 0) { + lines.push('', '## Optional', ''); + for (const page of optional) { + lines.push( + `- [${page.data.title}](${siteUrl}${page.url}.md)${page.data.description ? `: ${page.data.description}` : ''}`, + ); + } + } + + return new Response(lines.join('\n').replace(/\/\.md\)/g, '/index.md)') + '\n', { + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, + }); +} diff --git a/app/mcp/route.ts b/app/mcp/route.ts new file mode 100644 index 0000000..cc38011 --- /dev/null +++ b/app/mcp/route.ts @@ -0,0 +1,29 @@ +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { registerSearchTool, registerSourceTools } from 'fumadocs-core/mcp'; +import { docsLlms, source } from '@/lib/source'; +import { searchServer } from '@/lib/search'; +import { appName } from '@/lib/shared'; + +const handler = createMcpHandler(() => { + const mcp = new McpServer({ + name: appName, + version: '1.0.0', + }); + + registerSourceTools(mcp, source, docsLlms); + registerSearchTool(mcp, searchServer); + + return mcp; +}); + +export function GET(request: Request) { + return handler.fetch(request); +} + +export function POST(request: Request) { + return handler.fetch(request); +} + +export function DELETE(request: Request) { + return handler.fetch(request); +} diff --git a/app/og/[...slug]/route.tsx b/app/og/[...slug]/route.tsx new file mode 100644 index 0000000..7655c79 --- /dev/null +++ b/app/og/[...slug]/route.tsx @@ -0,0 +1,26 @@ +import { source } from '@/lib/source'; +import { notFound } from 'next/navigation'; +import { generateOGImage } from 'fumadocs-ui/og'; +import { appName, getPageImageUrl } from '@/lib/shared'; + +export const revalidate = false; + +export async function GET(_req: Request, { params }: RouteContext<'/og/[...slug]'>) { + const { slug } = await params; + const page = source.getPage(slug.slice(0, -1)); + if (!page) notFound(); + + return generateOGImage({ + title: page.data.title, + description: page.data.description, + site: appName, + primaryColor: 'rgba(243, 97, 23, 0.35)', + primaryTextColor: 'rgb(243, 97, 23)', + }); +} + +export function generateStaticParams() { + return source.getPages().map((page) => ({ + slug: getPageImageUrl(page).segments, + })); +} diff --git a/careers/software-engineer-backend.mdx b/careers/software-engineer-backend.mdx deleted file mode 100644 index 26746f5..0000000 --- a/careers/software-engineer-backend.mdx +++ /dev/null @@ -1,67 +0,0 @@ ---- -title: "Software Engineer - Backend" ---- -- Location: `New York City` -- Salary: `$125k - $185k + 0.5% - 1.5% equity` -- Years of experience: `3+` -- Skills: `Go, TypeScript, SQL (PostgreSQL, SQLite), Express, Drizzle ORM` - -## About Pangolin - -Pangolin delivers identity-aware remote access to internal apps and services. Our platform replaces legacy VPNs and simplifies secure access to infrastructure, applications, and developer environments. - -We build in the open and are self‑hosted by default so teams retain control over data and infrastructure. The system is policy‑driven, integrates with standard IdPs, exposes clear observability and health, and provides an API for automation. - -If you're interested in open-source auth and networking infrastructure, we'd love to chat. - -## About The Role - -As a Backend Software Engineer at Pangolin, you'll build and maintain the server that everything else in the system talks to: the API, the database schema, auth flows, and the integrations that connect Pangolin to identity providers and infrastructure. As an early hire, you'll play a foundational role in shaping how Pangolin is built as both a product and a company. - -## What You'll Do - -- Design and build APIs, database schemas, and background jobs that the rest of the platform depends on -- Implement and maintain integrations with IdPs (OAuth2, OIDC, SAML) and other external systems -- Write migrations and manage schema changes across a production database used by self-hosted deployments -- Troubleshoot issues that show up in the field, from race conditions to bad data to performance regressions -- Actively engage with the open-source community, providing support, and driving engagement on platforms like GitHub and Discord -- Ship quickly and get instant feedback from our large open-source user-base - -## What We're Looking For - -### High Level - -- Must be a current or recent graduate with more than 3 years of work experience in computer science -- Must be authorized to work in the US -- Must be comfortable with and excited by early stage startup culture and figuring things out as we go -- Must have your own ideas and be willing to communicate them - -### Other Technical Qualifications - -- Strong experience with Go or TypeScript backend services, ideally both -- Comfortable designing SQL schemas and writing migrations (PostgreSQL and/or SQLite) -- Familiarity with web identity and authentication standards: OAuth2, OIDC, SSO -- Experience with cloud infrastructure and operational technologies (Docker, Kubernetes, Linux, AWS) - -## What You Can Expect - -- Competitive salary -- In-person (New York City) -- Quiet work environment -- Small, trusting team of founders and engineers -- Relocation assistance -- Unlimited PTO - -## Process - -1. We will review your provided materials -2. Complete a quick “get to know you interview” with founders -3. 1-2 technical interviews -4. Fly out to meet the team for a day (paid if not hired) -5. Onboard! - -## How to Apply - -1. Email your resume/CV to [careers@pangolin.net](mailto:careers@pangolin.net) -2. Include your GitHub profile and highlight any past projects -3. Include a brief thoughtful message about why you're interested in Pangolin and this role diff --git a/careers/software-engineer-frontend.mdx b/careers/software-engineer-frontend.mdx deleted file mode 100644 index be70de2..0000000 --- a/careers/software-engineer-frontend.mdx +++ /dev/null @@ -1,67 +0,0 @@ ---- -title: "Software Engineer - Frontend" ---- -- Location: `New York City` -- Salary: `$125k - $185k + 0.5% - 1.5% equity` -- Years of experience: `3+` -- Skills: `TypeScript, NextJS, Tailwind, ShadCN` - -## About Pangolin - -Pangolin delivers identity-aware remote access to internal apps and services. Our platform replaces legacy VPNs and simplifies secure access to infrastructure, applications, and developer environments. - -We build in the open and are self‑hosted by default so teams retain control over data and infrastructure. The system is policy‑driven, integrates with standard IdPs, exposes clear observability and health, and provides an API for automation. - -If you're interested in open-source auth and networking infrastructure, we'd love to chat. - -## About The Role - -As a Frontend Software Engineer at Pangolin, you'll own the UI that administrators use to configure sites, resources, and policies. That means the dashboard itself, but also the forms, tables, and flows that make a system with a lot of moving parts feel simple to operate. As an early hire, you'll play a foundational role in shaping how Pangolin is built as both a product and a company. - -## What You'll Do - -- Design, build, and maintain the Pangolin dashboard (NextJS, Tailwind, ShadCN) -- Turn complex configuration (sites, resources, policies, users) into interfaces that are fast and hard to misuse -- Work closely with backend engineers to shape the APIs your UI consumes -- Troubleshoot issues reported by our self-hosted user-base, from rendering bugs to state management edge cases -- Actively engage with the open-source community, providing support, and driving engagement on platforms like GitHub and Discord -- Ship quickly and get instant feedback from our large open-source user-base - -## What We're Looking For - -### High Level - -- Must be a current or recent graduate with more than 3 years of work experience in computer science -- Must be authorized to work in the US -- Must be comfortable with and excited by early stage startup culture and figuring things out as we go -- Must have your own ideas and be willing to communicate them - -### Other Technical Qualifications - -- Extensive TypeScript and React experience, ideally with NextJS -- Strong eye for interaction design and can turn a rough idea into a usable form or flow -- Familiarity with web identity and authentication standards: OAuth2, OIDC, SSO -- Comfortable working against real backend APIs and SQL-backed data models - -## What You Can Expect - -- Competitive salary -- In-person (New York City) -- Quiet work environment -- Small, trusting team of founders and engineers -- Relocation assistance -- Unlimited PTO - -## Process - -1. We will review your provided materials -2. Complete a quick “get to know you interview” with founders -3. 1-2 technical interviews -4. Fly out to meet the team for a day (paid if not hired) -5. Onboard! - -## How to Apply - -1. Email your resume/CV to [careers@pangolin.net](mailto:careers@pangolin.net) -2. Include your GitHub profile and highlight any past projects -3. Include a brief thoughtful message about why you're interested in Pangolin and this role diff --git a/careers/software-engineer-lead.mdx b/careers/software-engineer-lead.mdx deleted file mode 100644 index 6309bbf..0000000 --- a/careers/software-engineer-lead.mdx +++ /dev/null @@ -1,68 +0,0 @@ ---- -title: "Software Lead" ---- -- Location: `New York City` -- Salary: `$160k - $220k + 1% - 2% equity` -- Years of experience: `6+` -- Skills: `TypeScript, Go, SQL (PostgreSQL, SQLite), NextJS, System Design, Team Leadership` - -## About Pangolin - -Pangolin delivers identity-aware remote access to internal apps and services. Our platform replaces legacy VPNs and simplifies secure access to infrastructure, applications, and developer environments. - -We build in the open and are self‑hosted by default so teams retain control over data and infrastructure. The system is policy‑driven, integrates with standard IdPs, exposes clear observability and health, and provides an API for automation. - -If you're interested in open-source auth and networking infrastructure, we'd love to chat. - -## About The Role - -As Software Lead at Pangolin, you'll set technical direction across the stack, from the dashboard down to the tunneling layer, and help the rest of the engineering team make good decisions faster. You'll still write code day to day, but you'll also own architecture calls, review design tradeoffs, and mentor engineers as the team grows. As an early hire, you'll play a foundational role in shaping how Pangolin is built as both a product and a company. - -## What You'll Do - -- Set direction on architecture: schema design, API boundaries, and how the frontend, backend, and tunneling components fit together -- Review code and designs across the team, and push back when something won't hold up in production -- Pair with founders on roadmap and technical strategy, including build-vs-buy and where to invest engineering time -- Mentor other engineers and help onboard new hires as the team grows -- Stay hands-on: ship features, fix bugs, and carry an on-call rotation like everyone else -- Actively engage with the open-source community, providing support, and driving engagement on platforms like GitHub and Discord - -## What We're Looking For - -### High Level - -- Must have 6+ years of professional software engineering experience, including time spent leading projects or mentoring other engineers -- Must be authorized to work in the US -- Must be comfortable with and excited by early stage startup culture and figuring things out as we go -- Must have your own ideas and be willing to communicate and defend them - -### Other Technical Qualifications - -- Strong full-stack experience across TypeScript and Go, with the judgment to know when to use each -- Track record of making architecture decisions that held up as a system and team grew -- Familiarity with web identity and authentication standards: OAuth2, OIDC, SSO -- Experience with cloud infrastructure and operational technologies (Docker, Kubernetes, Linux, AWS) -- Working knowledge of networking concepts: tunneling, WireGuard, proxies, SSL/TLS - -## What You Can Expect - -- Competitive salary -- In-person (New York City) -- Quiet work environment -- Small, trusting team of founders and engineers -- Relocation assistance -- Unlimited PTO - -## Process - -1. We will review your provided materials -2. Complete a quick “get to know you interview” with founders -3. 1-2 technical interviews -4. Fly out to meet the team for a day (paid if not hired) -5. Onboard! - -## How to Apply - -1. Email your resume/CV to [careers@pangolin.net](mailto:careers@pangolin.net) -2. Include your GitHub profile and highlight any past projects -3. Include a brief thoughtful message about why you're interested in Pangolin and this role diff --git a/careers/software-engineer-networking.mdx b/careers/software-engineer-networking.mdx deleted file mode 100644 index 8918ace..0000000 --- a/careers/software-engineer-networking.mdx +++ /dev/null @@ -1,67 +0,0 @@ ---- -title: "Software Engineer - Networking" ---- -- Location: `New York City` -- Salary: `$125k - $185k + 0.5% - 1.5% equity` -- Years of experience: `3+` -- Skills: `Go, WireGuard, NAT traversal, TCP/IP, TLS` - -## About Pangolin - -Pangolin delivers identity-aware remote access to internal apps and services. Our platform replaces legacy VPNs and simplifies secure access to infrastructure, applications, and developer environments. - -We build in the open and are self‑hosted by default so teams retain control over data and infrastructure. The system is policy‑driven, integrates with standard IdPs, exposes clear observability and health, and provides an API for automation. - -If you're interested in open-source auth and networking infrastructure, we'd love to chat. - -## About The Role - -As a Networking Software Engineer at Pangolin, you'll work on the tunneling layer that the rest of the product depends on: Newt, Gerbil, and Olm, and the hole-punching and WireGuard plumbing that connects sites and clients without a traditional VPN. As an early hire, you'll play a foundational role in shaping how Pangolin is built as both a product and a company. - -## What You'll Do - -- Build and harden the hub-and-spoke, hole-punched, zero-trust tunneling system that connects sites, clients, and the control plane -- Work on WireGuard configuration, NAT traversal, and connection recovery across unreliable networks -- Debug connectivity issues that only show up in the field: odd router configs, symmetric NAT, flaky links -- Improve observability into tunnel health so problems surface before users report them -- Actively engage with the open-source community, providing support, and driving engagement on platforms like GitHub and Discord -- Ship quickly and get instant feedback from our large open-source user-base - -## What We're Looking For - -### High Level - -- Must be a current or recent graduate with more than 3 years of work experience in computer science -- Must be authorized to work in the US -- Must be comfortable with and excited by early stage startup culture and figuring things out as we go -- Must have your own ideas and be willing to communicate them - -### Other Technical Qualifications - -- Extensive Go experience and can write a little TypeScript -- Deep knowledge of networking concepts: tunneling, WireGuard, NAT traversal, proxies, SSL/TLS -- Experience with cloud infrastructure and operational technologies (Docker, Kubernetes, Linux, AWS) -- Comfortable debugging at the packet level when logs alone don't explain what's happening - -## What You Can Expect - -- Competitive salary -- In-person (New York City) -- Quiet work environment -- Small, trusting team of founders and engineers -- Relocation assistance -- Unlimited PTO - -## Process - -1. We will review your provided materials -2. Complete a quick “get to know you interview” with founders -3. 1-2 technical interviews -4. Fly out to meet the team for a day (paid if not hired) -5. Onboard! - -## How to Apply - -1. Email your resume/CV to [careers@pangolin.net](mailto:careers@pangolin.net) -2. Include your GitHub profile and highlight any past projects -3. Include a brief thoughtful message about why you're interested in Pangolin and this role diff --git a/changelog.mdx b/changelog.mdx deleted file mode 100644 index f173cb9..0000000 --- a/changelog.mdx +++ /dev/null @@ -1,13 +0,0 @@ ---- -title: "Changelog" -description: "Updates and announcements" ---- - - Added a new Wintergreen flavor. - - Released a new version of the Spearmint flavor, now with 10% more mint. - - - - Released a new version of the Spearmint flavor. - diff --git a/components/ai/ask-bar.tsx b/components/ai/ask-bar.tsx new file mode 100644 index 0000000..406e319 --- /dev/null +++ b/components/ai/ask-bar.tsx @@ -0,0 +1,47 @@ +'use client'; +import { useState, type FormEvent } from 'react'; +import { ArrowUp } from 'lucide-react'; +import { cn } from '@/lib/cn'; +import { useAISearchContext, useAskAI } from './search'; + +/** + * Mintlify-style "Ask a question..." bar that sticks to the bottom of the page content. + * Submitting opens the chat panel with the question; it hides while the panel is open. + */ +export function AskBar() { + const { open } = useAISearchContext(); + const ask = useAskAI(); + const [value, setValue] = useState(''); + + function onSubmit(e: FormEvent) { + e.preventDefault(); + const text = value.trim(); + if (!text) return; + ask(text); + setValue(''); + } + + return ( +
+
+ setValue(e.target.value)} + placeholder="Ask a question..." + aria-label="Ask AI about the docs" + tabIndex={open ? -1 : undefined} + /> + ⌘I + +
+
+ ); +} diff --git a/components/ai/ask-page.tsx b/components/ai/ask-page.tsx new file mode 100644 index 0000000..56fcb63 --- /dev/null +++ b/components/ai/ask-page.tsx @@ -0,0 +1,22 @@ +'use client'; +import { Sparkles } from 'lucide-react'; +import { buttonVariants } from 'fumadocs-ui/components/ui/button'; +import { cn } from '@/lib/cn'; +import { useAskAI } from './search'; + +export function AskAIAboutPage({ title }: { title: string }) { + const ask = useAskAI(); + return ( + + ); +} diff --git a/components/ai/search.tsx b/components/ai/search.tsx new file mode 100644 index 0000000..943b991 --- /dev/null +++ b/components/ai/search.tsx @@ -0,0 +1,580 @@ +'use client'; +import { + type ComponentProps, + createContext, + type ReactNode, + type SyntheticEvent, + use, + useEffect, + useEffectEvent, + useMemo, + useRef, + useState, +} from 'react'; +import { flushSync } from 'react-dom'; +import { ArrowUp, FileText, Loader2, RefreshCw, SearchIcon, Sparkles, Square, X } from 'lucide-react'; +import { cn } from '../../lib/cn'; +import { buttonVariants } from '../ui/button'; +import { useChat, type UseChatHelpers } from '@ai-sdk/react'; +import { DefaultChatTransport, type UIMessage } from 'ai'; +import { Markdown } from '../markdown'; + +export type ChatUIMessage = UIMessage< + never, + { + client: { + location: string; + }; + } +>; + + +const Context = createContext<{ + open: boolean; + setOpen: (open: boolean) => void; + chat: UseChatHelpers; +} | null>(null); + +export function AISearchPanelHeader({ className, ...props }: ComponentProps<'div'>) { + const { setOpen } = useAISearchContext(); + + return ( +
+
+

+ + Ask Pangolin AI +

+

+ Answers are generated from the docs and can be wrong. Check the linked pages. +

+
+ + +
+ ); +} + +export function AISearchInputActions() { + const { messages, status, setMessages, regenerate } = useChatContext(); + const isLoading = status === 'streaming'; + + if (messages.length === 0) return null; + + return ( + <> + {!isLoading && messages.at(-1)?.role === 'assistant' && ( + + )} + + + ); +} + +const suggestions = [ + 'How do I expose a web app running on my home server?', + 'What is the difference between a site and a client?', + 'How do I self-host Pangolin with Docker Compose?', + 'How do I connect Claude Code to the AI Gateway?', +]; + +function sendText(send: UseChatHelpers['sendMessage'], text: string) { + void send({ + role: 'user', + parts: [ + { type: 'data-client', data: { location: location.href } }, + { type: 'text', text }, + ], + }); +} + +/** open the panel and immediately ask `text` */ +export function useAskAI() { + const { setOpen, chat } = useAISearchContext(); + return (text: string) => { + setOpen(true); + if (chat.status === 'streaming' || chat.status === 'submitted') return; + sendText(chat.sendMessage, text); + }; +} + +const StorageKeyInput = '__ai_search_input'; +export function AISearchInput(props: ComponentProps<'form'>) { + const { status, sendMessage, stop } = useChatContext(); + const [input, setInput] = useState(() => { + try { + return localStorage.getItem(StorageKeyInput) ?? ''; + } catch { + return ''; + } + }); + const isLoading = status === 'streaming' || status === 'submitted'; + const onStart = (e?: SyntheticEvent) => { + e?.preventDefault(); + const message = input.trim(); + if (message.length === 0) return; + + sendText(sendMessage, message); + setInput(''); + try { + localStorage.removeItem(StorageKeyInput); + } catch { + // storage unavailable + } + }; + + useEffect(() => { + if (isLoading) document.getElementById('nd-ai-input')?.focus(); + }, [isLoading]); + + return ( +
+ { + setInput(e.target.value); + try { + localStorage.setItem(StorageKeyInput, e.target.value); + } catch { + // storage unavailable + } + }} + onKeyDown={(event) => { + // keyCode 229: Safari fires `compositionend` before this keydown, `isComposing` is already false + if (event.nativeEvent.isComposing || event.keyCode === 229) return; + if (!event.shiftKey && event.key === 'Enter') { + onStart(event); + } + }} + /> + {isLoading ? ( + + ) : ( + + )} +
+ ); +} + +function List(props: Omit, 'dir'>) { + const containerRef = useRef(null); + + useEffect(() => { + if (!containerRef.current) return; + function callback() { + const container = containerRef.current; + if (!container) return; + + container.scrollTo({ + top: container.scrollHeight, + behavior: 'instant', + }); + } + + const observer = new ResizeObserver(callback); + callback(); + + const element = containerRef.current?.firstElementChild; + + if (element) { + observer.observe(element); + } + + return () => { + observer.disconnect(); + }; + }, []); + + return ( +
+ {props.children} +
+ ); +} + +function Input(props: ComponentProps<'textarea'>) { + const ref = useRef(null); + const shared = cn('pg-chat-field col-start-1 row-start-1', props.className); + + return ( +
+