Build IP Sentinel operations console

This commit is contained in:
Codex
2026-08-09 15:22:28 +03:00
commit cf8c51e3e8
28 changed files with 7068 additions and 0 deletions

43
.gitignore vendored Normal file
View File

@@ -0,0 +1,43 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnpm-store/
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/.vinext/
/out/
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
.dev-output.log
.dev-error.log
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
/dist/
/.wrangler/
/outputs/
/work/

5
.openai/hosting.json Normal file
View File

@@ -0,0 +1,5 @@
{
"project_id": "appgprj_6a787035642c8191b105d60a8f8b111d",
"d1": null,
"r2": null
}

100
README.md Normal file
View File

@@ -0,0 +1,100 @@
# vinext-starter
A clean full-stack starter running on
[vinext](https://github.com/cloudflare/vinext), with optional Cloudflare D1 and
Drizzle support.
## Prerequisites
- Node.js `>=22.13.0`
## Quick Start
```bash
npm install
npm run dev
npm run build
```
This starter does not use `wrangler.jsonc`.
## Included Shape
- edit site code under `app/`
- `.openai/hosting.json` declares optional Sites D1 and R2 bindings
- `vite.config.ts` simulates declared bindings for local development
- `db/schema.ts` starts intentionally empty
- `examples/d1/` contains an optional D1 example surface
- `drizzle.config.ts` supports local migration generation when needed
## Workspace Auth Headers
Signed-in visitors receive both `oai-authenticated-user-id` and `oai-authenticated-user-email`. Private Sites require every visitor to sign in; public Sites may also have anonymous visitors, for whom neither header is present.
The user ID is stable for the same user on the same Site and different across Sites. Email and name are intended for display or contact purposes.
SIWC-authenticated workspace sites may also receive
`oai-authenticated-user-full-name` when the user's SIWC profile has a non-empty
`name` claim. The full-name value is percent-encoded UTF-8 and is accompanied by
`oai-authenticated-user-full-name-encoding: percent-encoded-utf-8`.
Treat the full name as optional and fall back to email when it is absent:
```tsx
import { headers } from "next/headers";
export default async function Home() {
const requestHeaders = await headers();
const userId = requestHeaders.get("oai-authenticated-user-id");
const email = requestHeaders.get("oai-authenticated-user-email");
const encodedFullName = requestHeaders.get("oai-authenticated-user-full-name");
const fullName =
encodedFullName &&
requestHeaders.get("oai-authenticated-user-full-name-encoding") ===
"percent-encoded-utf-8"
? decodeURIComponent(encodedFullName)
: null;
const displayName = fullName ?? email;
// ...
}
```
## Optional Dispatch-Owned ChatGPT Sign-In
Import the ready-to-use helpers from `app/chatgpt-auth.ts` when the site needs
optional or required ChatGPT sign-in:
- Use `getChatGPTUser()` for optional signed-in UI.
- Use `requireChatGPTUser(returnTo)` for server-rendered pages that should send
anonymous visitors through Sign in with ChatGPT.
- Use `chatGPTSignInPath(returnTo)` and `chatGPTSignOutPath(returnTo)` for
browser links or actions.
- Pass a same-origin relative `returnTo` path for the destination after sign-in
or sign-out. The helper validates and safely encodes it.
- Mark protected pages with `export const dynamic = "force-dynamic"` because
they depend on per-request identity headers.
Dispatch owns `/signin-with-chatgpt`, `/signout-with-chatgpt`, `/callback`, the
OAuth cookies, and identity header injection. Do not implement app routes for
those reserved paths. Routes that do not import and call the helper remain
anonymous-compatible.
SIWC establishes identity only; it does not prove workspace membership. Use the
Sites hosting platform's access policy controls for workspace-wide restrictions,
or enforce explicit server-side membership or allowlist checks.
Use SIWC for account pages, user-specific dashboards, saved records, and write
actions tied to the current ChatGPT user. Leave public content anonymous.
## Useful Commands
- `npm run dev`: start local development
- `npm run build`: verify the vinext build output
- `npm test`: build the starter and verify its rendered loading skeleton
- `npm run db:generate`: generate Drizzle migrations after schema changes
## Learn More
- [vinext Documentation](https://github.com/cloudflare/vinext)
- [Drizzle D1 Guide](https://orm.drizzle.team/docs/get-started/d1-new)

90
app/chatgpt-auth.ts Normal file
View File

@@ -0,0 +1,90 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export type ChatGPTUser = {
userId: string;
displayName: string;
email: string;
fullName: string | null;
};
const USER_ID_HEADER = "oai-authenticated-user-id";
const USER_EMAIL_HEADER = "oai-authenticated-user-email";
const USER_FULL_NAME_HEADER = "oai-authenticated-user-full-name";
const USER_FULL_NAME_ENCODING_HEADER =
"oai-authenticated-user-full-name-encoding";
const PERCENT_ENCODED_UTF8 = "percent-encoded-utf-8";
const SIGN_IN_PATH = "/signin-with-chatgpt";
const SIGN_OUT_PATH = "/signout-with-chatgpt";
const CALLBACK_PATH = "/callback";
export async function getChatGPTUser(): Promise<ChatGPTUser | null> {
const requestHeaders = await headers();
const userId = requestHeaders.get(USER_ID_HEADER);
const email = requestHeaders.get(USER_EMAIL_HEADER);
if (!userId || !email) return null;
const encodedFullName = requestHeaders.get(USER_FULL_NAME_HEADER);
const fullName =
encodedFullName &&
requestHeaders.get(USER_FULL_NAME_ENCODING_HEADER) === PERCENT_ENCODED_UTF8
? safeDecodeURIComponent(encodedFullName)
: null;
return {
userId,
displayName: fullName ?? email,
email,
fullName,
};
}
export async function requireChatGPTUser(
returnTo: string,
): Promise<ChatGPTUser> {
const user = await getChatGPTUser();
if (user) return user;
redirect(chatGPTSignInPath(returnTo));
}
export function chatGPTSignInPath(returnTo: string): string {
const safeReturnTo = safeRelativeReturnPath(returnTo);
return `${SIGN_IN_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
}
export function chatGPTSignOutPath(returnTo = "/"): string {
const safeReturnTo = safeRelativeReturnPath(returnTo);
return `${SIGN_OUT_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
}
function safeRelativeReturnPath(value: string): string {
if (!value.startsWith("/") || value.startsWith("//")) return "/";
let url: URL;
try {
url = new URL(value, "https://app.local");
} catch {
return "/";
}
if (url.origin !== "https://app.local") return "/";
if (isReservedAuthPath(url.pathname)) return "/";
return `${url.pathname}${url.search}${url.hash}`;
}
function isReservedAuthPath(pathname: string): boolean {
return (
pathname === SIGN_IN_PATH ||
pathname === SIGN_OUT_PATH ||
pathname === CALLBACK_PATH
);
}
function safeDecodeURIComponent(value: string): string | null {
try {
return decodeURIComponent(value);
} catch {
return null;
}
}

51
app/globals.css Normal file
View File

@@ -0,0 +1,51 @@
@import "tailwindcss";
:root { --ink:#18202b; --muted:#6f7885; --line:#e6e8ec; --navy:#172139; --blue:#2764e7; --canvas:#f5f6f8; }
* { box-sizing:border-box; }
body { margin:0; background:var(--canvas); color:var(--ink); font-family:var(--font-geist-sans),Arial,sans-serif; }
button,input { font:inherit; }
button { cursor:pointer; }
.topbar { height:68px; display:flex; align-items:center; padding:0 34px; background:#fff; border-bottom:1px solid var(--line); position:sticky; top:0; z-index:10; }
.brand { display:flex; align-items:center; gap:10px; font-size:17px; font-weight:760; letter-spacing:-.03em; width:225px; }
.brandMark { display:grid; place-items:center; width:31px; height:31px; background:var(--navy); color:#fff; border-radius:8px; font-size:11px; letter-spacing:.02em; }
nav { display:flex; align-self:stretch; gap:30px; }
nav button { border:0; background:none; color:#7a818b; font-size:13px; font-weight:600; position:relative; padding:0; }
nav button:hover, nav .navActive { color:var(--ink); }
nav .navActive:after { content:""; position:absolute; height:2px; background:var(--blue); bottom:0; left:0; right:0; }
.topActions { margin-left:auto; display:flex; align-items:center; gap:10px; font-size:12px; }
.topActions small { display:block; color:#8a919a; margin-top:2px; }
.iconButton { border:0; background:transparent; color:#c2c7ce; font-size:9px; margin-right:8px; }
.avatar { width:33px; height:33px; display:grid; place-items:center; background:#dce8ff; color:#2350a5; border-radius:50%; font-size:11px; font-weight:750; }
.workspace { max-width:1360px; margin:0 auto; padding:35px 34px 55px; }
.hero { display:flex; align-items:flex-end; justify-content:space-between; margin-bottom:24px; }
.eyebrow { color:#476386!important; letter-spacing:.13em; font-size:10px!important; font-weight:750; margin-bottom:9px!important; }
.hero h1 { margin:0; font-size:30px; line-height:1.15; letter-spacing:-.045em; }
.hero p { color:var(--muted); margin:8px 0 0; font-size:13px; }
.health { display:flex; align-items:center; gap:11px; background:#fff; padding:11px 15px; border:1px solid var(--line); border-radius:10px; font-size:11px; box-shadow:0 3px 12px #1f293708; }
.health small { display:block; color:#8b929c; margin-top:3px; }.pulse { width:8px; height:8px; background:#29a36a; border-radius:50%; box-shadow:0 0 0 5px #29a36a18; }
.lookup { display:flex; align-items:center; gap:15px; background:var(--navy); color:#fff; padding:20px 22px; border-radius:12px; box-shadow:0 9px 24px #1721391f; }
.lookupIcon { width:36px; height:36px; border:1px solid #ffffff26; border-radius:8px; display:grid; place-items:center; font-size:24px; }
.lookupCopy { min-width:235px; }.lookupCopy label { display:block; font-size:13px; font-weight:680; }.lookupCopy span { display:block; font-size:10px; color:#aab3c5; margin-top:4px; }
.lookup input { min-width:220px; flex:1; height:42px; background:#273149; border:1px solid #ffffff1b; color:#fff; border-radius:7px; padding:0 15px; outline:none; font-family:var(--font-geist-mono); font-size:12px; }
.lookup input:focus { border-color:#6c93f0; box-shadow:0 0 0 3px #2764e730; }.lookup input::placeholder { color:#8290a8; }
.primary { height:42px; border:0; padding:0 18px; border-radius:7px; background:#fff; color:#172139; font-weight:700; font-size:12px; }.primary span { margin-left:14px; color:var(--blue); }
.metrics { display:grid; grid-template-columns:repeat(4,1fr); gap:12px; margin:17px 0; }
.metrics article { background:#fff; border:1px solid var(--line); border-radius:10px; padding:16px 17px; }
.metricTop { display:flex; justify-content:space-between; align-items:center; color:#78818d; font-size:9px; letter-spacing:.09em; font-weight:700; }
.metricTop i { font-style:normal; display:grid; place-items:center; width:26px; height:26px; border-radius:7px; font-size:14px; }.blue{color:#3374e8;background:#eaf1ff}.violet{color:#6c55c7;background:#f0edff}.red{color:#dc504f;background:#fff0ef}.amber{color:#bb7917;background:#fff5e4}
.metrics article>strong { display:block; font-size:25px; letter-spacing:-.045em; margin:9px 0 5px; }.metrics article p { margin:0; color:#9299a2; font-size:10px; }.metrics b { color:#35946b; }.metrics em { color:#dc5c59; font-style:normal; font-weight:700; }
.mainGrid { display:grid; grid-template-columns:minmax(0,1fr) 320px; gap:14px; align-items:start; }
.panel { background:#fff; border:1px solid var(--line); border-radius:11px; overflow:hidden; }
.panelHead { min-height:67px; padding:15px 18px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); }.panel h2 { margin:0; font-size:14px; }.panelHead p { margin:5px 0 0; font-size:10px; color:#89919b; }
.filters { background:#f5f6f8; padding:3px; border-radius:7px; display:flex; }.filters button { border:0; background:transparent; color:#777f8b; border-radius:5px; padding:6px 10px; font-size:9px; }.filters button.active { background:#fff; color:#202936; box-shadow:0 1px 4px #00000012; font-weight:700; }
.tableWrap { overflow-x:auto; } table { width:100%; border-collapse:collapse; } th { padding:10px 14px; background:#fafbfc; text-align:left; color:#8a929d; font-size:8px; letter-spacing:.08em; } td { padding:12px 14px; border-top:1px solid #eff0f2; font-size:11px; } tbody tr { transition:.15s; cursor:pointer; } tbody tr:hover, tbody tr.selected { background:#f7f9fd; } td code,.detail code { font-family:var(--font-geist-mono); font-weight:650; font-size:11px; } td small { display:block; margin-top:4px; color:#949ba4; font-size:9px; } td strong { font-size:10px; }
.score { display:inline-grid; place-items:center; width:27px; height:27px; border-radius:50%; font-weight:750; font-size:10px; border:3px solid; }.score.critical{color:#cf4545;border-color:#f0a2a2}.score.elevated{color:#b2761b;border-color:#f2c779}.score.low{color:#28865d;border-color:#8fd2b4}
.status { display:inline-flex; align-items:center; gap:5px; border-radius:20px; padding:5px 8px; font-size:8px; font-weight:750; }.status:before { content:""; width:5px; height:5px; border-radius:50%; }.status.review{color:#9a691e;background:#fff3dd}.status.review:before{background:#d69832}.status.blocked{color:#b63b3c;background:#ffeded}.status.blocked:before{background:#d64d4e}.status.allowed{color:#247e59;background:#e9f7f0}.status.allowed:before{background:#35a574}
.viewAll,.fullReport { width:100%; border:0; border-top:1px solid var(--line); background:#fff; color:#46628e; padding:12px; font-size:9px; font-weight:700; }.viewAll:hover,.fullReport:hover { background:#f7f9fc; }
.detailHead { padding:16px 17px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); }.detailHead p { margin:0 0 6px; font-size:8px; color:#8f97a2; letter-spacing:.1em; font-weight:700; }
.riskBlock { display:flex; align-items:center; gap:15px; padding:18px 17px; border-bottom:1px solid var(--line); }.riskRing { width:63px; height:63px; border-radius:50%; border:5px solid; display:flex; align-items:baseline; justify-content:center; padding-top:16px; }.riskRing strong{font-size:20px}.riskRing span{font-size:8px;color:#8a929c}.riskRing.critical{border-color:#ef807a;color:#c43d3a}.riskRing.elevated{border-color:#e6b155;color:#a46a13}.riskRing.low{border-color:#64ba94;color:#277853}.riskBlock p{margin:0;color:#8b929c;font-size:9px}.riskBlock h3{margin:3px 0;font-size:14px}.riskBlock small{font-size:8px;color:#969da6}
.signal { display:flex; justify-content:space-between; align-items:center; margin:0 17px; padding:12px 0; border-bottom:1px solid #eef0f2; }.signal span { font-size:8px; color:#969da6; letter-spacing:.08em; }.signal strong { font-size:9px; max-width:160px; text-align:right; }
.actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:16px 17px; }.actions button { border:1px solid #dfe2e6; background:#fff; border-radius:6px; padding:9px 5px; font-size:9px; font-weight:700; }.actions button:hover { background:#f7f8fa; }.actions .danger { background:#c94747; color:#fff; border-color:#c94747; }.actions .danger:hover { background:#b63e3e; }
.toast { position:fixed; right:28px; bottom:28px; background:#182235; color:#fff; border-radius:9px; padding:12px 17px; box-shadow:0 10px 28px #17213942; font-size:11px; display:flex; gap:9px; align-items:center; }.toast span { color:#62d49e; }
@media(max-width:900px){.topbar{padding:0 18px}.brand{width:auto;margin-right:28px}.topActions>div:last-child{display:none}.workspace{padding:26px 18px}.lookup{flex-wrap:wrap}.lookupCopy{min-width:calc(100% - 55px)}.lookup input{min-width:0}.metrics{grid-template-columns:1fr 1fr}.mainGrid{grid-template-columns:1fr}.detail{order:-1}nav{gap:18px}.health{display:none}}
@media(max-width:620px){nav{display:none}.hero h1{font-size:25px}.lookup input,.primary{width:100%;flex:auto}.metrics{grid-template-columns:1fr 1fr}.metrics article{padding:13px}.metrics article>strong{font-size:21px}.panelHead{align-items:flex-start;gap:12px;flex-direction:column}.filters{width:100%;overflow:auto}.filters button{flex:1;white-space:nowrap}.mainGrid{display:flex;flex-direction:column}.detail,.activity{width:100%}th:nth-child(2),td:nth-child(2),th:nth-child(3),td:nth-child(3){display:none}.topbar{height:60px}.workspace{padding-top:22px}.hero{margin-bottom:18px}}

16
app/layout.tsx Normal file
View File

@@ -0,0 +1,16 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] });
const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"] });
export const metadata: Metadata = {
title: "IP Sentinel — Network Intelligence",
description: "Internal IP reputation and network operations console.",
icons: { icon: "/favicon.svg", shortcut: "/favicon.svg" },
};
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return <html lang="en"><body className={`${geistSans.variable} ${geistMono.variable}`}>{children}</body></html>;
}

93
app/page.tsx Normal file
View File

@@ -0,0 +1,93 @@
"use client";
import { useMemo, useState } from "react";
type Event = { ip: string; country: string; city: string; network: string; score: number; status: "Review" | "Blocked" | "Allowed"; seen: string; requests: string };
const initialEvents: Event[] = [
{ ip: "185.220.101.42", country: "Germany", city: "Falkenstein", network: "AS60729 · Zwiebelfreunde", score: 96, status: "Review", seen: "12 sec ago", requests: "842" },
{ ip: "45.134.26.91", country: "Netherlands", city: "Amsterdam", network: "AS9009 · M247 Europe", score: 88, status: "Blocked", seen: "1 min ago", requests: "391" },
{ ip: "104.28.208.87", country: "United States", city: "San Francisco", network: "AS13335 · Cloudflare", score: 18, status: "Allowed", seen: "3 min ago", requests: "126" },
{ ip: "193.32.162.14", country: "Romania", city: "Bucharest", network: "AS209605 · UAB Host Baltic", score: 73, status: "Review", seen: "7 min ago", requests: "214" },
{ ip: "34.160.111.145", country: "United States", city: "Mountain View", network: "AS396982 · Google Cloud", score: 7, status: "Allowed", seen: "11 min ago", requests: "98" },
];
function riskLabel(score: number) { return score >= 80 ? "Critical" : score >= 60 ? "Elevated" : "Low"; }
export default function Home() {
const [query, setQuery] = useState("");
const [filter, setFilter] = useState("All traffic");
const [events, setEvents] = useState(initialEvents);
const [selected, setSelected] = useState<Event>(initialEvents[0]);
const [notice, setNotice] = useState("");
const visible = useMemo(() => events.filter((e) => filter === "All traffic" || e.status === filter), [events, filter]);
const inspect = () => {
const value = query.trim();
if (!value) return;
const found = events.find((e) => e.ip === value);
if (found) setSelected(found);
else setSelected({ ip: value, country: "Unknown", city: "Unresolved", network: "No network record", score: 42, status: "Review", seen: "Just now", requests: "1" });
setNotice(`Inspection opened for ${value}`);
setTimeout(() => setNotice(""), 2400);
};
const decide = (status: "Blocked" | "Allowed") => {
setSelected((current) => ({ ...current, status }));
setEvents((current) => current.map((event) => event.ip === selected.ip ? { ...event, status } : event));
setNotice(`${selected.ip} ${status.toLowerCase()}`);
setTimeout(() => setNotice(""), 2400);
};
return (
<main>
<header className="topbar">
<div className="brand"><span className="brandMark">IP</span><span>Sentinel</span></div>
<nav aria-label="Primary navigation">
<button className="navActive">Overview</button><button>Investigations</button><button>Rules</button><button>Reports</button>
</nav>
<div className="topActions"><button className="iconButton" aria-label="Notifications"></button><div className="avatar">MK</div><div><strong>Maya Kim</strong><small>Security Ops</small></div></div>
</header>
<div className="workspace">
<section className="hero">
<div><p className="eyebrow">NETWORK INTELLIGENCE</p><h1>Good morning, Maya.</h1><p>Heres what your perimeter has seen in the last 24 hours.</p></div>
<div className="health"><span className="pulse"/><div><strong>All systems operational</strong><small>Last sync 18 seconds ago</small></div></div>
</section>
<section className="lookup" aria-label="IP address lookup">
<div className="lookupIcon"></div><div className="lookupCopy"><label htmlFor="ip">Inspect an IP address</label><span>Reputation, location, network and recent activity</span></div>
<input id="ip" value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={(e) => e.key === "Enter" && inspect()} placeholder="Enter IPv4 or IPv6 address" />
<button className="primary" onClick={inspect}>Inspect IP <span></span></button>
</section>
<section className="metrics" aria-label="Traffic summary">
<article><div className="metricTop"><span>REQUESTS SCANNED</span><i className="blue"></i></div><strong>2.48M</strong><p><b>+12.4%</b> from yesterday</p></article>
<article><div className="metricTop"><span>UNIQUE ADDRESSES</span><i className="violet"></i></div><strong>18,294</strong><p><b>+3.1%</b> from yesterday</p></article>
<article><div className="metricTop"><span>THREATS BLOCKED</span><i className="red">!</i></div><strong>1,847</strong><p><em>8.2%</em> from yesterday</p></article>
<article><div className="metricTop"><span>REVIEW QUEUE</span><i className="amber"></i></div><strong>24</strong><p><b>7 critical</b> need attention</p></article>
</section>
<div className="mainGrid">
<section className="panel activity">
<div className="panelHead"><div><h2>Recent activity</h2><p>Addresses with notable behavior</p></div><div className="filters">{["All traffic","Review","Blocked","Allowed"].map((name) => <button key={name} className={filter === name ? "active" : ""} onClick={() => setFilter(name)}>{name}</button>)}</div></div>
<div className="tableWrap"><table><thead><tr><th>IP ADDRESS</th><th>LOCATION / NETWORK</th><th>REQUESTS</th><th>RISK</th><th>STATUS</th><th></th></tr></thead><tbody>{visible.map((event) => <tr key={event.ip} className={selected.ip === event.ip ? "selected" : ""} onClick={() => setSelected(event)}><td><code>{event.ip}</code><small>{event.seen}</small></td><td><strong>{event.city}, {event.country}</strong><small>{event.network}</small></td><td>{event.requests}</td><td><span className={`score ${riskLabel(event.score).toLowerCase()}`}>{event.score}</span></td><td><span className={`status ${event.status.toLowerCase()}`}>{event.status}</span></td><td></td></tr>)}</tbody></table></div>
<button className="viewAll">View all investigations </button>
</section>
<aside className="panel detail">
<div className="detailHead"><div><p>SELECTED ADDRESS</p><code>{selected.ip}</code></div><span className={`status ${selected.status.toLowerCase()}`}>{selected.status}</span></div>
<div className="riskBlock"><div className={`riskRing ${riskLabel(selected.score).toLowerCase()}`}><strong>{selected.score}</strong><span>/100</span></div><div><p>Risk assessment</p><h3>{riskLabel(selected.score)} risk</h3><small>Based on 14 intelligence signals</small></div></div>
<div className="signal"><span>TOR EXIT NODE</span><strong>{selected.score > 80 ? "Detected" : "Not detected"}</strong></div>
<div className="signal"><span>ABUSE REPORTS</span><strong>{selected.score > 80 ? "47 in 30 days" : "None recent"}</strong></div>
<div className="signal"><span>NETWORK</span><strong>{selected.network.split(" · ")[0]}</strong></div>
<div className="signal"><span>LOCATION</span><strong>{selected.city}, {selected.country}</strong></div>
<div className="actions"><button className="danger" onClick={() => decide("Blocked")}>Block address</button><button onClick={() => decide("Allowed")}>Add to allowlist</button></div>
<button className="fullReport">Open full investigation </button>
</aside>
</div>
</div>
{notice && <div className="toast" role="status"><span></span>{notice}</div>}
</main>
);
}

View File

@@ -0,0 +1,45 @@
import { access, cp, mkdir, rm } from "node:fs/promises";
import { resolve } from "node:path";
import type { Plugin } from "vite";
async function exists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return false;
}
throw error;
}
}
// Packages Sites metadata and migrations after Vite finishes compiling.
export function sites(): Plugin {
let root = process.cwd();
return {
name: "sites",
apply: "build",
configResolved(config) {
root = config.root;
},
async closeBundle() {
const outputDirectory = resolve(root, "dist", ".openai");
const hostingConfig = resolve(root, ".openai", "hosting.json");
const drizzleSource = resolve(root, "drizzle");
await rm(outputDirectory, { recursive: true, force: true });
await mkdir(outputDirectory, { recursive: true });
if (await exists(hostingConfig)) {
await cp(hostingConfig, resolve(outputDirectory, "hosting.json"));
}
if (await exists(drizzleSource)) {
await cp(drizzleSource, resolve(outputDirectory, "drizzle"), {
recursive: true,
});
}
},
};
}

13
db/index.ts Normal file
View File

@@ -0,0 +1,13 @@
import { env } from "cloudflare:workers";
import { drizzle } from "drizzle-orm/d1";
import * as schema from "./schema";
export function getDb() {
if (!env.DB) {
throw new Error(
"Cloudflare D1 binding `DB` is unavailable. Set the `d1` field in .openai/hosting.json to `DB` or let your control plane inject the real binding values before using the database."
);
}
return drizzle(env.DB, { schema });
}

4
db/schema.ts Normal file
View File

@@ -0,0 +1,4 @@
// Intentionally empty by default.
// Add Drizzle tables here when the site actually needs a database.
// See examples/d1/db/schema.ts for an opt-in example.
export {};

7
drizzle.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
out: "./drizzle",
schema: "./db/schema.ts",
dialect: "sqlite",
});

View File

@@ -0,0 +1,5 @@
{
"version": "7",
"dialect": "sqlite",
"entries": []
}

41
eslint.config.mjs Normal file
View File

@@ -0,0 +1,41 @@
import { defineConfig, globalIgnores } from "eslint/config";
import eslint from "@eslint/js";
import next from "@next/eslint-plugin-next";
import jsxA11y from "eslint-plugin-jsx-a11y";
import react from "eslint-plugin-react";
import reactHooks from "eslint-plugin-react-hooks";
import globals from "globals";
import tseslint from "typescript-eslint";
const eslintConfig = defineConfig([
globalIgnores([
".next/**",
"dist/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
eslint.configs.recommended,
...tseslint.configs.recommended,
react.configs.flat.recommended,
react.configs.flat["jsx-runtime"],
reactHooks.configs.flat["recommended-latest"],
jsxA11y.flatConfigs.recommended,
next.configs["core-web-vitals"],
{
languageOptions: {
globals: {
...globals.browser,
...globals.node,
...globals.serviceworker,
},
},
settings: {
react: {
version: "detect",
},
},
},
]);
export default eslintConfig;

View File

@@ -0,0 +1,58 @@
import { desc } from "drizzle-orm";
import { getDb } from "../../../../../db";
import { notes } from "../../../db/schema";
function toRouteErrorMessage(error: unknown) {
const message = error instanceof Error ? error.message : "Unexpected error";
const detail =
error instanceof Error && error.cause instanceof Error ? error.cause.message : "";
const combined = `${message}\n${detail}`;
if (combined.includes("no such table") || combined.includes('from "notes"')) {
return "The notes table is unavailable. Generate the migration locally with `npm run db:generate`, then deploy so the platform can apply the generated SQL to the real D1 database.";
}
return message;
}
export async function GET() {
try {
const db = getDb();
const rows = await db
.select()
.from(notes)
.orderBy(desc(notes.createdAt), desc(notes.id))
.limit(20);
return Response.json({ notes: rows });
} catch (error) {
return Response.json(
{ error: toRouteErrorMessage(error) },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const payload = (await request.json()) as {
title?: string;
content?: string;
};
const title = payload.title?.trim() ?? "";
const content = payload.content?.trim() ?? "";
if (!title) {
return Response.json({ error: "title is required" }, { status: 400 });
}
const db = getDb();
const [note] = await db.insert(notes).values({ title, content }).returning();
return Response.json({ note }, { status: 201 });
} catch (error) {
return Response.json(
{ error: toRouteErrorMessage(error) },
{ status: 500 }
);
}
}

9
examples/d1/db/schema.ts Normal file
View File

@@ -0,0 +1,9 @@
import { sql } from "drizzle-orm";
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const notes = sqliteTable("notes", {
id: integer("id").primaryKey({ autoIncrement: true }),
title: text("title").notNull(),
content: text("content").notNull().default(""),
createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
});

5
next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
import "vinext/types";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

7
next.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

46
package.json Normal file
View File

@@ -0,0 +1,46 @@
{
"name": "site-creator-vinext-starter",
"version": "0.1.0",
"private": true,
"engines": {
"node": ">=22.13.0"
},
"scripts": {
"dev": "vinext dev",
"build": "vinext build",
"start": "vinext start",
"test": "npm run build && node --test tests/rendered-html.test.mjs",
"lint": "eslint . --ignore-pattern dist --ignore-pattern .next",
"db:generate": "drizzle-kit generate"
},
"dependencies": {
"drizzle-orm": "0.45.2",
"react": "19.2.6",
"react-dom": "19.2.6"
},
"devDependencies": {
"@cloudflare/vite-plugin": "1.37.1",
"@eslint/js": "9.39.4",
"@next/eslint-plugin-next": "16.2.6",
"@tailwindcss/postcss": "4.2.1",
"@types/node": "22.19.19",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.2",
"@vitejs/plugin-rsc": "0.5.26",
"drizzle-kit": "0.31.10",
"eslint": "9.39.4",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "7.1.1",
"globals": "16.4.0",
"react-server-dom-webpack": "19.2.6",
"tailwindcss": "4.2.1",
"typescript": "5.9.3",
"typescript-eslint": "8.59.3",
"vinext": "1.0.0-beta.2",
"vite": "8.0.13",
"wrangler": "4.92.0"
},
"type": "module"
}

6188
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

7
postcss.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

6
public/favicon.svg Normal file
View File

@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 19.2727C22 20.779 20.779 22 19.2727 22H14.7273C13.221 22 12 20.779 12 19.2727V12H19.2727C20.779 12 22 13.221 22 14.7273V19.2727Z" fill="#68C4FF"/>
<path d="M20 2C21.1046 2 22 2.89543 22 4V7C22 8.10457 21.1046 9 20 9H17C15.8954 9 15 8.10457 15 7V4C15 2.89543 15.8954 2 17 2H20Z" fill="#0C79D8"/>
<path d="M7 15C8.10457 15 9 15.8954 9 17V20C9 21.1046 8.10457 22 7 22H4C2.89543 22 2 21.1046 2 20V17C2 15.8954 2.89543 15 4 15H7Z" fill="#0C79D8"/>
<path d="M12 12H4.72727C3.22104 12 2 10.779 2 9.27273V4.72727C2 3.22104 3.22104 2 4.72727 2H9.27273C10.779 2 12 3.22104 12 4.72727V12Z" fill="#2E9EFF"/>
</svg>

After

Width:  |  Height:  |  Size: 712 B

1
public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 392 B

1
public/globe.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/window.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 386 B

View File

@@ -0,0 +1,91 @@
import assert from "node:assert/strict";
import { access, readFile, readdir } from "node:fs/promises";
import test from "node:test";
const developmentPreviewMeta =
/<meta(?=[^>]*\bname=["']codex-preview["'])(?=[^>]*\bcontent=["']development["'])[^>]*>/i;
const templateRoot = new URL("../", import.meta.url);
const previewRoot = new URL("../app/_sites-preview/", import.meta.url);
async function render() {
const workerUrl = new URL("../dist/server/index.js", import.meta.url);
workerUrl.searchParams.set("test", `${process.pid}-${Date.now()}`);
const { default: worker } = await import(workerUrl.href);
return worker.fetch(
new Request("http://localhost/", {
headers: { accept: "text/html" },
}),
{
ASSETS: {
fetch: async () => new Response("Not found", { status: 404 }),
},
},
{
waitUntil() {},
passThroughOnException() {},
},
);
}
test("server-renders the starter loading skeleton", async () => {
const response = await render();
assert.equal(response.status, 200);
assert.match(response.headers.get("content-type") ?? "", /^text\/html\b/i);
const html = await response.text();
assert.match(html, developmentPreviewMeta);
assert.match(html, /<title>Your site is taking shape<\/title>/i);
assert.match(html, /Building your site/);
assert.match(html, /Your site is taking shape/);
assert.match(
html,
/Your first version will appear here automatically when its ready\./,
);
assert.doesNotMatch(html, /Codex/);
assert.match(html, /react-loading-skeleton/);
assert.match(html, /role="status"/);
});
test("keeps the loading skeleton scoped and disposable", async () => {
const [preview, css, page, layout, packageJson, files] = await Promise.all([
readFile(new URL("SkeletonPreview.tsx", previewRoot), "utf8"),
readFile(new URL("preview.css", previewRoot), "utf8"),
readFile(new URL("../app/page.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/layout.tsx", import.meta.url), "utf8"),
readFile(new URL("../package.json", import.meta.url), "utf8"),
readdir(previewRoot),
]);
assert.deepEqual(files.sort(), ["SkeletonPreview.tsx", "preview.css"]);
assert.match(preview, /from "react-loading-skeleton"/);
assert.match(preview, /baseColor="#eceae7"/);
assert.match(preview, /highlightColor="#f9f8f6"/);
assert.match(preview, /duration=\{2\.8\}/);
assert.match(preview, /sites-skeleton-search-placeholder/);
assert.match(packageJson, /"react-loading-skeleton": "3\.5\.0"/);
const shellIndex = preview.indexOf('className="sites-skeleton-shell"');
const statusIndex = preview.indexOf('className="sites-skeleton-status"');
assert.ok(shellIndex >= 0 && statusIndex > shellIndex);
assert.match(css, /position:\s*fixed/);
assert.match(css, /inset:\s*0/);
assert.match(css, /opacity:\s*0\.52/);
assert.match(css, /prefers-reduced-motion:\s*reduce/);
assert.doesNotMatch(css, /#020617|canvas|pets|progress/i);
assert.doesNotMatch(
preview,
/loading-spinner|status-mark|status-progress|canvas|cookie|random/i,
);
assert.match(page, /export const metadata:\s*Metadata/);
assert.match(page, /"codex-preview": "development"/);
assert.match(page, /<SkeletonPreview \/>/);
assert.match(layout, /title:\s*"Starter Project"/);
assert.doesNotMatch(layout, /codex-preview|_sites-preview|themeColor|\bViewport\b/);
assert.doesNotMatch(css, /(^|\s)(html|body)\s*\{/m);
await assert.rejects(
access(new URL("public/_sites-preview", templateRoot)),
);
});

29
tsconfig.json Normal file
View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}

59
vite.config.ts Normal file
View File

@@ -0,0 +1,59 @@
import vinext from "vinext";
import { defineConfig } from "vite";
import hostingConfig from "./.openai/hosting.json";
import { sites } from "./build/sites-vite-plugin";
const SITE_CREATOR_PLACEHOLDER_DATABASE_ID =
"00000000-0000-4000-8000-000000000000";
const { d1, r2 } = hostingConfig;
// macOS Seatbelt blocks FSEvents, so Codex previews need polling for HMR.
const isCodexSeatbeltSandbox = process.env.CODEX_SANDBOX === "seatbelt";
const localBindingConfig = {
main: "./worker/index.ts",
compatibility_flags: ["nodejs_compat"],
d1_databases: d1
? [
{
binding: d1,
database_name: "site-creator-d1",
database_id: SITE_CREATOR_PLACEHOLDER_DATABASE_ID,
},
]
: [],
r2_buckets: r2
? [
{
binding: r2,
bucket_name: "site-creator-r2",
},
]
: [],
};
export default defineConfig(async () => {
// Keep Wrangler and Miniflare state project-local. These are non-secret tool
// settings; application environment belongs in ignored `.env*` files.
process.env.WRANGLER_WRITE_LOGS ??= "false";
process.env.WRANGLER_LOG_PATH ??= ".wrangler/logs";
process.env.MINIFLARE_REGISTRY_PATH ??= ".wrangler/registry";
// Wrangler snapshots its log path while the Cloudflare plugin is imported.
const { cloudflare } = await import("@cloudflare/vite-plugin");
return {
server: isCodexSeatbeltSandbox
? { watch: { useFsEvents: false, usePolling: true } }
: undefined,
plugins: [
vinext(),
sites(),
cloudflare({
viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] },
config: localBindingConfig,
}),
],
};
});

47
worker/index.ts Normal file
View File

@@ -0,0 +1,47 @@
/** Cloudflare Worker entry point for the vinext-starter template. */
import { handleImageOptimization, DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES } from "vinext/server/image-optimization";
import handler from "vinext/server/app-router-entry";
interface Env {
ASSETS: Fetcher;
DB: D1Database;
IMAGES: {
input(stream: ReadableStream): {
transform(options: Record<string, unknown>): {
output(options: { format: string; quality: number }): Promise<{ response(): Response }>;
};
};
};
}
interface ExecutionContext {
waitUntil(promise: Promise<unknown>): void;
passThroughOnException(): void;
}
// Image security config. SVG sources with .svg extension auto-skip the
// optimization endpoint on the client side (served directly, no proxy).
// To route SVGs through the optimizer (with security headers), set
// dangerouslyAllowSVG: true in next.config.js and uncomment below:
// const imageConfig: ImageConfig = { dangerouslyAllowSVG: true };
const worker = {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/_vinext/image") {
const allowedWidths = [...DEFAULT_DEVICE_SIZES, ...DEFAULT_IMAGE_SIZES];
return handleImageOptimization(request, {
fetchAsset: (path) => env.ASSETS.fetch(new Request(new URL(path, request.url))),
transformImage: async (body, { width, format, quality }) => {
const result = await env.IMAGES.input(body).transform(width > 0 ? { width } : {}).output({ format, quality });
return result.response();
},
}, allowedWidths);
}
return handler.fetch(request, env, ctx);
},
};
export default worker;