From 06fd5e3220f928cd05523198a9c0fb6a42d8994c Mon Sep 17 00:00:00 2001 From: Mateus Date: Tue, 14 Jul 2026 16:13:40 -0300 Subject: [PATCH 01/12] feat(web): add support and analytics controls Keep analytics consent-gated and disabled on self-hosted defaults. Keep third-party scripts outside the authenticated knowledge workspace. --- .env.example | 2 + README.md | 3 + WEBAPP_ARCHITECTURE.md | 168 ++++++++++++++++++ apps/web/src/app/layout.tsx | 2 + .../components/public-site/public-pages.tsx | 30 +++- .../components/third-party-integrations.tsx | 162 +++++++++++++++++ 6 files changed, 365 insertions(+), 2 deletions(-) create mode 100644 WEBAPP_ARCHITECTURE.md create mode 100644 apps/web/src/components/third-party-integrations.tsx diff --git a/.env.example b/.env.example index 9829c8a..78c90a2 100755 --- a/.env.example +++ b/.env.example @@ -45,6 +45,8 @@ BERRYBRAIN_DONATION_URL= NEXT_PUBLIC_BERRYBRAIN_API_URL=/berrybrain NEXT_PUBLIC_BERRYBRAIN_BASE_PATH=/berrybrain NEXT_PUBLIC_BERRYBRAIN_ASSET_PREFIX=/berrybrain +# Optional. Leave empty on self-hosted instances to disable Google Analytics. +NEXT_PUBLIC_GOOGLE_ANALYTICS_ID= BERRYBRAIN_AUTH_RATE_LIMIT_WINDOW_SECONDS=900 BERRYBRAIN_AUTH_RATE_LIMIT_MAX_ATTEMPTS=8 BERRYBRAIN_AUTH_LOCKOUT_MINUTES=15 diff --git a/README.md b/README.md index b59b3ad..afb6a97 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ There is no central BerryBrain account, SaaS tenant, billing gate, demo mode, or ![Source Available](https://img.shields.io/badge/source--available-yes-3C8F5A) ![License](https://img.shields.io/badge/license-non--commercial-lightgrey) +[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/T5D823778G) + --- ## Table of Contents @@ -622,6 +624,7 @@ Edit `.env` and set at minimum: | `BERRYBRAIN_INTERNAL_API_URL` | Server-side API origin used by the web proxy. Defaults to `http://api:8000`; use `http://127.0.0.1:8000` when running Web outside Docker. | | `BERRYBRAIN_ENV_FILE` | Optional Compose environment file path. Defaults to `.env`; useful for isolated smoke tests or multiple self-hosted instances. | | `BERRYBRAIN_DONATION_URL` | Optional donation link shown/documented by the operator; no payment processing is built in. | +| `NEXT_PUBLIC_GOOGLE_ANALYTICS_ID` | Optional analytics property for public pages. Empty by default on self-hosted instances; tracking still requires visitor consent. Never send note or account data as analytics events. | | `BERRYBRAIN_PUBLIC_APP_URL` | Public base URL of the web app (used in emails/links). | | `BERRYBRAIN_CORS_ORIGINS` | Comma-separated allowed web origins. | | `SMTP_*` | Optional legacy email delivery settings. Not required for default self-hosted setup. | diff --git a/WEBAPP_ARCHITECTURE.md b/WEBAPP_ARCHITECTURE.md new file mode 100644 index 0000000..e7a7942 --- /dev/null +++ b/WEBAPP_ARCHITECTURE.md @@ -0,0 +1,168 @@ +# BerryBrain Web App Architecture + +Status: architecture baseline for the `webapp` branch. No hosted authentication or browser +knowledge persistence is considered production-ready until the security and recovery gates below +pass. + +## Product modes + +BerryBrain must keep two explicit deployment modes: + +1. **Self-hosted** — the existing FastAPI, worker, SQLite/Postgres, vault, and local owner account. +2. **Hosted web app** — Netlify serves the Next.js frontend; personal knowledge remains on the + user's device unless the user deliberately exports or later enables an encrypted sync service. + +The hosted mode must not silently fall back to a server database for notes, attachments, graph +content, embeddings, prompts, or provider keys. + +## Non-negotiable storage decision + +Browser storage is viable for a local-only knowledge workspace, with hard limits: + +- Use **OPFS** for Markdown, attachments, and encrypted archive blobs. +- Use **IndexedDB** for metadata, graph records, job state, retrieval indexes, and migrations. +- Use `navigator.storage.persist()` and show whether durable storage was granted. +- Use `localStorage` only for non-sensitive UI preferences. Never store passwords, session tokens, + provider keys, note content, recovery codes, or encryption keys there. +- Encrypt sensitive browser records with Web Crypto AES-GCM. Derive wrapping keys with a reviewed, + memory-hard KDF; maintain format and migration versions. +- Provide encrypted export/import and scheduled backup reminders. +- Detect quota pressure, failed writes, private browsing, unsupported OPFS, eviction risk, and + partial migrations before accepting writes. + +Browser persistence is device- and origin-bound. Clearing site data, browser eviction, changing +origin, or losing the device can permanently remove local knowledge. Email recovery cannot recover +browser-only notes. Cross-device recovery requires user-controlled encrypted backup or an optional +zero-knowledge sync service in a later phase. + +## Account data reality + +Login, email confirmation, 2FA, password recovery, sessions, abuse prevention, and account deletion +cannot be implemented without persistent authentication state. A static Netlify site and Umbler +SMTP alone are insufficient. Umbler sends email; it is not an authentication database. + +Hosted mode may store only this minimal control-plane data: + +- normalized email and verification state; +- password hash, never the password; +- encrypted TOTP secret and hashed recovery codes; +- hashed email verification and recovery challenges with expiry and attempt counters; +- hashed sessions, device label, creation, expiry, revocation, and last-use metadata; +- security audit events and rate-limit state with short retention; +- privacy consent version and account deletion state. + +No notes or cognitive data belong in this account store. The UI and privacy policy must disclose the +minimal account metadata before signup. + +## Recommended runtime + +- Netlify: static/SSR frontend and narrowly scoped server functions. +- Authentication API: reuse the reviewed BerryBrain FastAPI security core, deployed on a runtime + with stable secrets and a transactional Postgres database. Do not place SMTP or database secrets + in the browser bundle. +- Email: Umbler SMTP over TLS from the authentication API only. +- Knowledge runtime: Web Workers for parsing, graph operations, embeddings where supported, and + IndexedDB/OPFS access through one versioned repository layer. +- AI: bring-your-own-provider key encrypted locally. Prefer direct provider calls only where CORS + and provider policy allow it; otherwise use an explicit stateless relay that disables body logs + and never persists prompts or keys. + +## Authentication controls + +- Argon2id password hashing with calibrated memory/time parameters and automatic rehashing. +- Password length and compromised-password checks; no arbitrary composition rules. +- Generic signup, login, resend, and recovery responses to prevent account enumeration. +- One-time, hashed, expiring email confirmation and recovery tokens. +- TOTP authenticator 2FA with QR setup, confirmation challenge, recovery codes, replay prevention, + and step-up authentication for destructive actions. +- Email OTP is recovery/verification fallback, not the preferred second factor. +- HttpOnly, Secure, host-only, SameSite cookies; session rotation after login and 2FA. +- CSRF tokens bound to the session for every state-changing request. +- Progressive per-account and per-network rate limits, exponential delays, lockout notifications, + challenge escalation, and short-retention abuse telemetry. +- Session/device list, revoke-one, revoke-all, password-change revocation, and idle/absolute expiry. +- Reauthentication for email, password, 2FA, export, and account deletion changes. +- Constant-time secret comparisons and transaction-safe challenge consumption. + +## Web security controls + +- Strict CSP with nonces/hashes; no `unsafe-eval`; smallest possible `connect-src` allowlist. +- HSTS, `frame-ancestors 'none'`, `X-Content-Type-Options`, strict referrer policy, and restrictive + permissions policy. +- Validate redirects and origins; reject malformed hosts and cross-origin credential requests. +- Escape rendered Markdown, sanitize HTML, reject dangerous attachment previews, and isolate PDFs, + images, audio, and video in sandboxed viewers. +- Validate request size, MIME and file signatures; quarantine unsupported active content. +- No secrets in `NEXT_PUBLIC_*`, source maps, logs, analytics, error reports, URL parameters, or + client-side build artifacts. +- Dependency pinning, lockfile review, secret scanning, SAST, dependency review, SBOM, signed release + artifacts, and automated security updates. +- Third-party scripts stay outside the knowledge workspace. The authenticated app uses a native + Ko-fi link; the public site may load the Ko-fi widget. Google Analytics runs only on public routes, + only after consent, with advertising signals disabled. +- Treat interception proxies as clients, not special attack classes. Enforce authorization, + validation, replay resistance, rate limits, and audit controls server-side. + +## Privacy and analytics + +- Official Google Analytics property: `G-36YL9QLC5K`. +- Self-hosted analytics default: disabled. Operators may set + `NEXT_PUBLIC_GOOGLE_ANALYTICS_ID` for their own property. +- No note titles, paths, search terms, graph questions, provider names, emails, user IDs, or content + in analytics events. +- Consent must be explicit, revocable, versioned, and independent from product access. +- Publish retention, subprocessors, legal basis, data-subject request, deletion, and breach handling + details before hosted signup opens. + +## Delivery phases + +### Phase 0 — Boundaries and threat model + +- [x] Create `webapp` from published `origin/main`. +- [x] Define self-hosted and hosted modes. +- [x] Reject `localStorage` as the knowledge database. +- [x] Define minimal account metadata and no-knowledge-data boundary. +- [ ] Produce STRIDE/LINDDUN threat models and data-flow diagrams. +- [ ] Approve retention periods, subprocessors, and incident owner. + +### Phase 1 — Browser knowledge repository + +- [ ] Versioned OPFS/IndexedDB repository interface. +- [ ] Transactional note and graph writes with crash recovery. +- [ ] Encryption envelope, key lifecycle, migration tests, and lock screen. +- [ ] Quota, persistence, eviction, private-mode, and browser support diagnostics. +- [ ] Encrypted backup/export/import with corruption checks. +- [ ] Web Worker pipeline and deterministic migration fixtures. + +### Phase 2 — Hosted account control plane + +- [ ] Transactional account schema and migration process. +- [ ] Signup, generic email confirmation, login, logout, and session lifecycle. +- [ ] Umbler SMTP adapter with TLS, retry, idempotency, and redacted logs. +- [ ] TOTP 2FA, recovery codes, step-up auth, and secure reset. +- [ ] Progressive rate limits, enumeration resistance, audit events, and alerts. +- [ ] Account privacy/settings screen, export, revoke sessions, and deletion workflow. + +### Phase 3 — Hosted cognitive runtime + +- [ ] Browser parser, graph store, retrieval index, and jobs state machine. +- [ ] Local/provider model router with explicit data-egress confirmation. +- [ ] No-log stateless relay only where direct provider access is impossible. +- [ ] Explainable insights and graph inference using local evidence. +- [ ] Performance budgets and background cancellation/recovery. + +### Phase 4 — Security and release gates + +- [ ] Unit, integration, browser E2E, migration, and storage-failure suites. +- [ ] Authentication abuse tests, CSRF, XSS, IDOR, replay, fixation, and race tests. +- [ ] CSP/headers tests and proof that third-party scripts cannot access the workspace. +- [ ] Secret scan, SAST, dependency review, SBOM, container scan, and signed artifacts. +- [ ] Independent security review and remediation before public hosted accounts. +- [ ] Restore and deletion drills with published evidence. + +## Definition of ready + +Hosted BerryBrain is ready only when account recovery works without exposing knowledge data, local +knowledge survives tested reload/crash/migration scenarios, encrypted backups restore cleanly, +third-party scripts cannot execute in the workspace, all authentication abuse tests pass, and the +privacy UI truthfully describes every persisted field and external data flow. diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index f35eeed..fbb2f44 100755 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata, Viewport } from "next"; import { PwaRegister } from "@/components/pwa-register"; +import { ThirdPartyIntegrations } from "@/components/third-party-integrations"; import berrylogo from "../../public/berrylogo.png"; import "./globals.css"; @@ -50,6 +51,7 @@ export default function RootLayout({ {children} + ); diff --git a/apps/web/src/components/public-site/public-pages.tsx b/apps/web/src/components/public-site/public-pages.tsx index d57031c..dd4f612 100644 --- a/apps/web/src/components/public-site/public-pages.tsx +++ b/apps/web/src/components/public-site/public-pages.tsx @@ -26,6 +26,8 @@ const legalContent: Record = { title: "Privacy", body: [ "BerryBrain is local-first. User notes remain in the configured vault unless the user enables external providers.", + "Google Analytics is disabled by default on self-hosted instances and requires explicit visitor consent on the official website. Analytics is configured without advertising signals or ad personalization.", + "The Ko-fi donation widget is provided by an external service. Opening or using it is subject to Ko-fi's own privacy practices.", "When cloud AI, email, or external enrichment is configured, BerryBrain records provider, model, purpose, status, and evidence so the user can understand what left the local system.", "Account data is separated from note content. Security events may include timestamps, IP-derived request metadata, session state, and administrative actions needed to protect the service.", "Knowledge data is processed to build notes, concepts, graph edges, insights, and retrieval indexes. The product should never hide whether a result came from local processing or a configured external provider.", @@ -36,6 +38,7 @@ const legalContent: Record = { title: "GDPR and LGPD", body: [ "BerryBrain is designed around data minimization, transparency, and user-controlled processing. Notes and graph data are treated as personal knowledge data.", + "Optional website analytics remains disabled until consent is granted. Consent can be declined without losing access to BerryBrain.", "Self-hosted operators control access, correction, export, and deletion of their local instance data. Local vault files remain under the operator's storage control.", "Processing purposes include local authentication, instance security, note indexing, graph construction, retrieval, insight generation, and optional provider integrations configured by the local owner.", "For LGPD and GDPR requests, include enough context to verify ownership. Do not include passwords, API keys, tokens, or private notes in email.", @@ -81,6 +84,7 @@ const footerGroups = [ ["Docs", "/docs"], ["Open BerryBrain", "/brain"], ["GitHub", GITHUB_URL], + ["Donate", "https://ko-fi.com/T5D823778G"], ], }, { @@ -88,6 +92,7 @@ const footerGroups = [ links: [ ["Security", "legal:security"], ["Privacy", "legal:privacy"], + ["Privacy choices", "consent:analytics"], ["GDPR/LGPD", "legal:gdpr-lgpd"], ], }, @@ -152,6 +157,7 @@ const mobileNavLinks = [ ...nav, ["Security", "legal:security"], ["Privacy", "legal:privacy"], + ["Privacy choices", "consent:analytics"], ["GDPR/LGPD", "legal:gdpr-lgpd"], ["Terms", "legal:terms"], ["Contact", "legal:contact"], @@ -307,7 +313,17 @@ export function PublicShell({
    {mobileNavLinks.map(([label, href]) => (
  • - {href.startsWith("legal:") ? ( + {href === "consent:analytics" ? ( + + ) : href.startsWith("legal:") ? ( + ) : href.startsWith("legal:") ? ( diff --git a/apps/web/src/components/third-party-integrations.tsx b/apps/web/src/components/third-party-integrations.tsx new file mode 100644 index 0000000..938d10a --- /dev/null +++ b/apps/web/src/components/third-party-integrations.tsx @@ -0,0 +1,162 @@ +"use client"; + +import Script from "next/script"; +import { usePathname } from "next/navigation"; +import { useCallback, useEffect, useRef, useState } from "react"; + +const OFFICIAL_GOOGLE_ANALYTICS_ID = "G-36YL9QLC5K"; +const ANALYTICS_CONSENT_KEY = "bb_analytics_consent"; + +declare global { + interface Window { + dataLayer?: unknown[]; + gtag?: (...args: unknown[]) => void; + kofiWidgetOverlay?: { + draw: (account: string, options: Record) => void; + }; + } +} + +function analyticsIdForCurrentDeployment() { + const configuredId = process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_ID?.trim(); + if (configuredId && /^G-[A-Z0-9]{6,20}$/i.test(configuredId)) return configuredId; + if (typeof window === "undefined") return ""; + const hostname = window.location.hostname.toLowerCase(); + return hostname === "optlabs.com.br" || hostname === "www.optlabs.com.br" + ? OFFICIAL_GOOGLE_ANALYTICS_ID + : ""; +} + +function GoogleAnalytics({ enabled }: { enabled: boolean }) { + const [analyticsId, setAnalyticsId] = useState(""); + const [consent, setConsent] = useState<"granted" | "denied" | null>(null); + const configured = useRef(false); + + useEffect(() => { + setAnalyticsId(enabled ? analyticsIdForCurrentDeployment() : ""); + const readConsent = () => { + const saved = window.localStorage.getItem(ANALYTICS_CONSENT_KEY); + setConsent(saved === "granted" || saved === "denied" ? saved : null); + }; + readConsent(); + window.addEventListener("bb:analytics-consent", readConsent); + return () => window.removeEventListener("bb:analytics-consent", readConsent); + }, [enabled]); + + const choose = (value: "granted" | "denied") => { + const wasGranted = consent === "granted"; + window.localStorage.setItem(ANALYTICS_CONSENT_KEY, value); + setConsent(value); + if (wasGranted && value === "denied") window.location.reload(); + }; + + const configure = useCallback(() => { + if (!analyticsId || configured.current) return; + window.dataLayer = window.dataLayer || []; + window.gtag = (...args: unknown[]) => window.dataLayer?.push(args); + window.gtag("js", new Date()); + window.gtag("config", analyticsId, { + anonymize_ip: true, + allow_google_signals: false, + allow_ad_personalization_signals: false, + }); + configured.current = true; + }, [analyticsId]); + + if (!analyticsId) return null; + + return ( + <> + {consent === "granted" && ( + <> +