Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ The backend now uses an auth-first request model. Users register into a tenant,

Protected document and question APIs require `Authorization: Bearer $ACCESS_TOKEN`. Auth state is backed by Prisma-managed PostgreSQL tables for user sessions, refresh tokens, verification tokens, password reset tokens, and audit events.

The web application uses server-side Next.js routes for browser authentication. Access and refresh tokens are stored in `httpOnly` cookies, not in browser JavaScript. Public privacy, terms, cookie, and security pages are available from the footer and consent menu.

Implemented auth flows:

| Flow | Endpoint |
Expand Down
10 changes: 10 additions & 0 deletions apps/web/app/api/web/auth/email/verify/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { NextRequest } from "next/server";
import { proxyPublic } from "@/lib/server-gateway";

export async function POST(request: NextRequest) {
return proxyPublic({
method: "POST",
path: "/api/auth/email/verify",
body: await request.json()
});
}
6 changes: 6 additions & 0 deletions apps/web/app/api/web/auth/login/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { loginThroughGateway } from "@/lib/server-gateway";

export async function POST(request: NextRequest) {
return loginThroughGateway(await request.json());
}
5 changes: 5 additions & 0 deletions apps/web/app/api/web/auth/logout/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { logoutThroughGateway } from "@/lib/server-gateway";

export async function POST() {
return logoutThroughGateway();
}
8 changes: 8 additions & 0 deletions apps/web/app/api/web/auth/me/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { proxyPrivate } from "@/lib/server-gateway";

export async function GET() {
return proxyPrivate({
method: "GET",
path: "/api/auth/me"
});
}
10 changes: 10 additions & 0 deletions apps/web/app/api/web/auth/register/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { NextRequest } from "next/server";
import { proxyPublic } from "@/lib/server-gateway";

export async function POST(request: NextRequest) {
return proxyPublic({
method: "POST",
path: "/api/auth/register",
body: await request.json()
});
}
17 changes: 17 additions & 0 deletions apps/web/app/api/web/documents/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { NextRequest } from "next/server";
import { proxyPrivate } from "@/lib/server-gateway";

export async function GET() {
return proxyPrivate({
method: "GET",
path: "/api/documents"
});
}

export async function POST(request: NextRequest) {
return proxyPrivate({
method: "POST",
path: "/api/documents",
body: await request.json()
});
}
10 changes: 10 additions & 0 deletions apps/web/app/api/web/questions/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { NextRequest } from "next/server";
import { proxyPrivate } from "@/lib/server-gateway";

export async function POST(request: NextRequest) {
return proxyPrivate({
method: "POST",
path: "/api/questions",
body: await request.json()
});
}
33 changes: 33 additions & 0 deletions apps/web/app/cookies/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { LegalPage } from "@/components/LegalPage";

export default function CookiesPage() {
return (
<LegalPage
eyebrow="Cookies"
title="Cookie Policy"
intro="Grounded uses cookies to keep sessions secure and to remember consent choices. Optional categories are disabled unless the user allows them."
sections={[
{
title: "Necessary Cookies",
body: "Necessary cookies support authentication, session security, CSRF-resistant same-site behavior, and cookie consent storage. These cookies are required for the application to work."
},
{
title: "Analytics Cookies",
body: "Analytics cookies may be used later to understand product usage, performance, and reliability. They are not required for authentication or workspace access."
},
{
title: "Preference Cookies",
body: "Preference cookies may remember interface choices such as consent settings, workspace display preferences, or non-sensitive UI configuration."
},
{
title: "Marketing Cookies",
body: "Marketing cookies may support campaign attribution or product communication measurement. They are optional and should not be enabled without consent."
},
{
title: "Changing Choices",
body: "Users can choose necessary-only cookies or allow all categories from the consent menu. Necessary cookies remain enabled because login and security depend on them."
}
]}
/>
);
}
106 changes: 106 additions & 0 deletions apps/web/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,112 @@ button {
gap: 20px;
}

.cookie-consent {
background: rgba(251, 250, 246, 0.96);
border: 1px solid var(--line);
border-radius: 16px;
bottom: 22px;
box-shadow: var(--shadow);
display: grid;
gap: 18px;
left: 22px;
max-width: min(680px, calc(100vw - 44px));
padding: 22px;
position: fixed;
z-index: 80;
}

.cookie-consent h2 {
font-size: 24px;
letter-spacing: -0.02em;
margin: 0 0 8px;
}

.cookie-consent p {
color: var(--muted);
font-size: 14px;
line-height: 1.5;
margin: 0;
}

.cookie-links,
.cookie-actions,
.cookie-options {
display: flex;
flex-wrap: wrap;
gap: 10px;
}

.cookie-links {
font-size: 13px;
font-weight: 700;
margin-top: 12px;
}

.cookie-links a {
color: #0b7d52;
}

.cookie-options {
background: #eff6ec;
border: 1px solid var(--line-strong);
border-radius: 12px;
padding: 12px;
}

.cookie-options label {
align-items: center;
display: inline-flex;
font-size: 13px;
font-weight: 700;
gap: 8px;
}

.legal-page {
padding: clamp(54px, 8vw, 92px) 0;
}

.legal-page h1 {
font-size: clamp(42px, 6vw, 72px);
letter-spacing: -0.045em;
line-height: 0.98;
margin: 0 0 18px;
}

.legal-intro {
color: var(--muted);
font-size: 18px;
line-height: 1.55;
margin: 0;
max-width: 760px;
}

.legal-sections {
display: grid;
gap: 14px;
margin-top: 34px;
max-width: 860px;
}

.legal-section {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 14px;
padding: 22px;
}

.legal-section h2 {
font-size: 21px;
letter-spacing: -0.01em;
margin: 0 0 8px;
}

.legal-section p {
color: var(--muted);
line-height: 1.6;
margin: 0;
}

.auth-page {
align-items: stretch;
display: grid;
Expand Down
2 changes: 2 additions & 0 deletions apps/web/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
import { CookieConsent } from "@/components/CookieConsent";
import { CustomCursor } from "@/components/CustomCursor";
import "./globals.css";

Expand All @@ -14,6 +15,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
<body>
<CustomCursor />
{children}
<CookieConsent />
</body>
</html>
);
Expand Down
33 changes: 33 additions & 0 deletions apps/web/app/privacy/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { LegalPage } from "@/components/LegalPage";

export default function PrivacyPage() {
return (
<LegalPage
eyebrow="Privacy"
title="Privacy Policy"
intro="Grounded is designed for source-grounded work with tenant-scoped documents. This policy explains what the product needs to process and what should never be treated casually."
sections={[
{
title: "Data We Process",
body: "Grounded processes account details, tenant information, uploaded document content, questions, generated answers, citations, security events, and operational logs needed to run the service."
},
{
title: "How Data Is Used",
body: "Data is used to authenticate users, isolate tenant workspaces, index documents, answer questions with citations, operate the platform, investigate abuse, and improve reliability."
},
{
title: "Tenant Isolation",
body: "Documents and retrieval requests are scoped to the active tenant. Cross-tenant access is not an accepted product behavior and must be treated as a security defect."
},
{
title: "Security",
body: "Authentication cookies are httpOnly and required for product access. Sensitive tokens are not exposed to browser JavaScript in the production-oriented web flow."
},
{
title: "Retention",
body: "Retention periods depend on deployment policy. Self-hosted operators are responsible for configuring backups, logs, object storage, and database retention."
}
]}
/>
);
}
33 changes: 33 additions & 0 deletions apps/web/app/security/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { LegalPage } from "@/components/LegalPage";

export default function SecurityPage() {
return (
<LegalPage
eyebrow="Security"
title="Security Overview"
intro="Grounded treats authentication, tenant isolation, source integrity, and auditability as product requirements, not later decorations."
sections={[
{
title: "Authentication",
body: "Web authentication uses server-managed httpOnly cookies. Access and refresh tokens are not exposed to browser JavaScript in the web application flow."
},
{
title: "Session Rotation",
body: "The backend issues short-lived access tokens and rotating refresh tokens. Refresh token reuse is treated as a compromised session family."
},
{
title: "Tenant Boundaries",
body: "Document ingestion, listing, retrieval, and question answering are scoped to the tenant claim attached to the authenticated session."
},
{
title: "Audit Events",
body: "Security-sensitive operations such as login, refresh, logout, password change, and session revocation are designed to write audit events."
},
{
title: "Responsible Disclosure",
body: "Security issues should be reported privately with reproduction steps, affected routes, expected impact, and any relevant logs or screenshots."
}
]}
/>
);
}
33 changes: 33 additions & 0 deletions apps/web/app/terms/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { LegalPage } from "@/components/LegalPage";

export default function TermsPage() {
return (
<LegalPage
eyebrow="Terms"
title="Terms of Service"
intro="These terms describe the expected use of Grounded during development, evaluation, and future hosted operation."
sections={[
{
title: "Use Of The Service",
body: "Grounded is intended for document-grounded retrieval, citation inspection, tenant-scoped knowledge work, and related operational workflows."
},
{
title: "User Responsibilities",
body: "Users are responsible for the documents they upload, the access they grant to team members, and the legal basis for processing information inside a workspace."
},
{
title: "No Unverified Reliance",
body: "Grounded provides source-backed answers, but users must review citations and source documents before making legal, financial, medical, or operational decisions."
},
{
title: "Acceptable Use",
body: "The service must not be used to bypass access controls, extract another tenant's information, upload unlawful content, or attempt to degrade platform security."
},
{
title: "Changes",
body: "Terms may change as Grounded moves from local development toward hosted and self-hosted release models."
}
]}
/>
);
}
4 changes: 1 addition & 3 deletions apps/web/components/AuthPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { useRouter } from "next/navigation";
import { FormEvent, useState } from "react";
import { Logo } from "@/components/Chrome";
import { ApiError, login, register, verifyEmail } from "@/lib/api";
import { storeSession } from "@/lib/auth-session";

type AuthPageProps = {
mode: "login" | "register";
Expand Down Expand Up @@ -53,13 +52,12 @@ export function AuthPage({ mode }: AuthPageProps) {
await verifyEmail(registration.dev_verification_token);
}

const session = await login({
await login({
email,
password,
device_label: navigator.userAgent
});

storeSession(session);
router.push("/app");
} catch (error) {
setStatus({
Expand Down
4 changes: 4 additions & 0 deletions apps/web/components/Chrome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ export function Footer() {
{label}
</Link>
))}
<Link href="/privacy">Privacy</Link>
<Link href="/terms">Terms</Link>
<Link href="/cookies">Cookies</Link>
<Link href="/security">Security</Link>
<Link href="/login">Log in</Link>
</div>
<span className="mono">2026 · self-hostable</span>
Expand Down
Loading
Loading