From f1903e1e6130c269083b8661a67801e27c6045f3 Mon Sep 17 00:00:00 2001 From: Giorgio Gilestro Date: Sun, 24 May 2026 00:08:02 +0200 Subject: [PATCH] public: landing + pricing + legal pages, apex-ready, lawyer-reviewed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the unauthenticated surface that's needed to invite outsiders: - Landing (/) — dual-purpose root: dashboard for logged-in users, landing for everyone else. New maybe_current_user soft-auth helper in app/auth.py supports it without disturbing the per-route require_token deps on /news, /log, /upload, /settings. - About, Pricing, Disclaimer, Terms, Privacy — own router (app/routers/public.py), no auth dep, shared public_base layout (brand link, thin nav, footer with legal links + ICO ref + date). - Editorial positioning: news aggregator with a macro brain; tagline "Understand markets. Don't gamble on them."; anti-trading-as-gambling stance carried through About and Landing. Legal pass following an independent lawyer-style review: - Privacy: explicit UK-GDPR Art. 6 lawful-basis section; Art. 22 automated-decision line; explicit consent for sessionStorage sync key (PECR); 30-day IP-log retention; Art. 21 objection right; Children clause; Art. 33/34 breach-notification clause; international-transfer mechanism (IDTA + UK Addendum). ICO registration ZC098928 surfaced at the top. - Pricing: paid-card AI-portfolio-analysis bullet rewritten to remove advice-shaped wording ("what would invalidate the posture" gone); added italic carve-out citing FSMA / FCA COBS. - Disclaimer: separate EU/EEA carve-out + MAR 596/2014 Art. 3(1)(34) commentator safe-harbour; "qualifies the Terms" line; hallucination wording fixed. - Terms: cl.4 explicit AI-training prohibition + harassment line; cl.5 CCR 2013 14-day cancellation; cl.7 softened AI copyright claim under CDPA s.9(3) ambiguity; cl.8 proportionate suspension + pro-rata refund for paid users; cl.10 CRA 2015 Pt 1 statutory-rights carve-out from the liability cap; cl.11 right to close account on material change; cl.12 non-exclusive jurisdiction + UK consumer local courts. Code-side enforcement of the Privacy claim: - openrouter.py: outbound OpenRouter calls now carry X-OR-Allow-Training: false. DeepSeek doesn't expose a per-request flag; the Privacy page discloses this caveat verbatim. Apex domain prep: - branding.APP_URL flipped to https://read.markets (was app.). DNS for the apex already resolves; pending operator NPM step is a cert that covers the bare apex + a 301 from app.read.markets. No hard-coded subdomain references remain in code (verified with grep). Nav + chrome: - app dropdown gains Pricing / Terms / Privacy / Disclaimer links. - login.html gains a small legal-links footer for the highest-leverage moment to surface them. Co-Authored-By: Claude Opus 4.7 --- app/auth.py | 31 ++++ app/branding.py | 19 +- app/main.py | 4 + app/routers/pages.py | 39 ++++- app/routers/public.py | 68 +++++++ app/services/openrouter.py | 7 + app/static/css/cassandra.css | 287 ++++++++++++++++++++++++++++++ app/templates/about.html | 76 ++++++++ app/templates/base.html | 6 +- app/templates/disclaimer.html | 110 ++++++++++++ app/templates/landing.html | 103 +++++++++++ app/templates/login.html | 7 + app/templates/pricing.html | 96 ++++++++++ app/templates/privacy.html | 311 +++++++++++++++++++++++++++++++++ app/templates/public_base.html | 64 +++++++ app/templates/terms.html | 214 +++++++++++++++++++++++ app/templates_env.py | 4 + 17 files changed, 1436 insertions(+), 10 deletions(-) create mode 100644 app/routers/public.py create mode 100644 app/templates/about.html create mode 100644 app/templates/disclaimer.html create mode 100644 app/templates/landing.html create mode 100644 app/templates/pricing.html create mode 100644 app/templates/privacy.html create mode 100644 app/templates/public_base.html create mode 100644 app/templates/terms.html diff --git a/app/auth.py b/app/auth.py index 06a31a4..27218ca 100644 --- a/app/auth.py +++ b/app/auth.py @@ -161,6 +161,37 @@ async def require_auth( ) +async def maybe_current_user( + request: Request, + authorization: str | None = Header(default=None), +) -> CurrentUser | None: + """Soft-auth: same resolution as `require_auth`, but returns None on + miss instead of raising. Used on dual-purpose routes (e.g. `/` where + a logged-in user sees the dashboard and a logged-out visitor sees the + landing page) and on the public marketing/legal pages, so their + templates can show a "Dashboard" link when a session is present.""" + s = get_settings() + + if s.CASSANDRA_TOKEN and authorization and authorization.lower().startswith("bearer "): + provided = authorization.split(" ", 1)[1].strip() + if secrets.compare_digest(provided.encode(), s.CASSANDRA_TOKEN.encode()): + principal = CurrentUser(is_admin=True, user=None) + request.state.current_user = principal + return principal + + cookie = request.cookies.get(SESSION_COOKIE_NAME) + if cookie: + uid = verify_session(cookie) + if uid is not None: + async with get_session_factory()() as db_session: + user = await get_user(db_session, uid) + if user is not None: + principal = CurrentUser(is_admin=False, user=user) + request.state.current_user = principal + return principal + return None + + def _raise_redirect_to_login(next_path: str = "/") -> None: # Some pages (login itself) are paths a redirect loop would be silly # to send back to. The auth router opts out of this dependency diff --git a/app/branding.py b/app/branding.py index 8f8bd17..1bd8f48 100644 --- a/app/branding.py +++ b/app/branding.py @@ -29,9 +29,26 @@ BRAND_NAME = "Read the Markets" BRAND_SHORT = "Read" DOMAIN = "read.markets" SITE_URL = "https://read.markets" -APP_URL = "https://app.read.markets" +# The app lives at the apex too — same host serves landing/legal *and* +# the dashboard. SITE_URL and APP_URL are kept as separate symbols so a +# future split (marketing/apex, app/subdomain) is a two-line change. +APP_URL = "https://read.markets" EMAIL_FROM_DEFAULT = f"noreply@{DOMAIN}" +# Marketing line — printed in the landing hero. Single source of truth so +# OG cards, email subjects, and the landing template stay in sync. The +# wording is a stance, not just a description: this is for people who +# treat investing and gambling as different activities. The "news +# aggregator / media service" framing still lives in the body copy and +# the disclaimer, where it does the legal distancing work. +TAGLINE = "Understand markets. Don't gamble on them." + +# Legal-page operator details. Placeholders until the user supplies real +# ones; only the legal pages read them. +LEGAL_OPERATOR = BRAND_NAME +OPERATOR_EMAIL = f"hello@{DOMAIN}" +OPERATOR_JURISDICTION = "United Kingdom" + DARK: dict[str, str] = { "bg": "#0a0e14", diff --git a/app/main.py b/app/main.py index 20ed7f0..a9dcb50 100644 --- a/app/main.py +++ b/app/main.py @@ -20,6 +20,7 @@ from app.logging import configure_logging, get_logger from app.routers import api as api_router from app.routers import auth as auth_router from app.routers import pages as pages_router +from app.routers import public as public_router from app.routers import sync as sync_router from app.routers import universe as universe_router from app.services.feeds_bootstrap import bootstrap_feeds @@ -85,4 +86,7 @@ app.include_router(auth_router.router, tags=["auth"]) app.include_router(api_router.router, prefix="/api", tags=["api"]) app.include_router(universe_router.router, prefix="/api", tags=["universe"]) app.include_router(sync_router.router, tags=["portfolio-sync"]) +# Public router (no auth dep) before pages_router so the marketing/legal +# paths can never collide with future authenticated routes. +app.include_router(public_router.router) app.include_router(pages_router.router, tags=["pages"]) diff --git a/app/routers/pages.py b/app/routers/pages.py index 46f58e0..a00bf56 100644 --- a/app/routers/pages.py +++ b/app/routers/pages.py @@ -8,7 +8,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse from sqlalchemy import desc, func, select from sqlalchemy.ext.asyncio import AsyncSession -from app.auth import CurrentUser, require_auth, require_token +from app.auth import CurrentUser, maybe_current_user, require_auth, require_token from app.config import get_settings, load_groups from app.db import get_session from app.models import Referral, StrategicLog, User @@ -16,26 +16,41 @@ from app.services.access import paid_status from app.services.referral_service import assign_code_if_missing from app.templates_env import templates -router = APIRouter(dependencies=[Depends(require_token)]) +# Router-level auth removed in favour of per-route deps so that `/` can be +# dual-purpose: logged-in users see the dashboard, logged-out visitors see +# the landing page. +router = APIRouter() @router.get("/", response_class=HTMLResponse) -async def dashboard(request: Request): +async def root_page( + request: Request, + cu: CurrentUser | None = Depends(maybe_current_user), +): + """Dual-purpose root: dashboard when authenticated, landing otherwise.""" + if cu is None: + return templates.TemplateResponse( + request, "landing.html", {"cu": None}, + ) s = get_settings() groups = load_groups(s.BASELINE_TOML, s.PORTFOLIO_TOML) return templates.TemplateResponse( request, "dashboard.html", - {"groups": list(groups.keys()), "anchor": s.CASSANDRA_ANCHOR_DATE}, + {"groups": list(groups.keys()), "anchor": s.CASSANDRA_ANCHOR_DATE, "cu": cu}, ) -@router.get("/news", response_class=HTMLResponse) +@router.get( + "/news", + response_class=HTMLResponse, + dependencies=[Depends(require_token)], +) async def news_page(request: Request): return templates.TemplateResponse(request, "news.html", {}) -@router.get("/upload") +@router.get("/upload", dependencies=[Depends(require_token)]) async def upload_page(request: Request): """Legacy bookmark — the import widget now lives in /settings.""" return RedirectResponse(url="/settings#import", status_code=302) @@ -69,7 +84,11 @@ def _log_page_context(target: date) -> dict: } -@router.get("/log", response_class=HTMLResponse) +@router.get( + "/log", + response_class=HTMLResponse, + dependencies=[Depends(require_token)], +) async def log_page( request: Request, session: AsyncSession = Depends(get_session), @@ -78,7 +97,11 @@ async def log_page( return templates.TemplateResponse(request, "log.html", _log_page_context(target)) -@router.get("/log/{day}", response_class=HTMLResponse) +@router.get( + "/log/{day}", + response_class=HTMLResponse, + dependencies=[Depends(require_token)], +) async def log_page_day( request: Request, day: str, diff --git a/app/routers/public.py b/app/routers/public.py new file mode 100644 index 0000000..a040ccd --- /dev/null +++ b/app/routers/public.py @@ -0,0 +1,68 @@ +"""Unauthenticated marketing + legal pages. + +This router carries no auth dependency — every route is reachable to +anonymous visitors and is also reachable to logged-in users (the +templates branch off `cu` to flip the top-right CTA between +"Sign in / sign up" and "Dashboard"). + +The dual-purpose root (`/`) lives in `app/routers/pages.py` because it +also has to render the dashboard when authenticated. Pure public-only +pages live here. +""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse + +from app.auth import CurrentUser, maybe_current_user +from app.templates_env import templates + + +router = APIRouter(tags=["public"]) + + +def _ctx(request: Request, cu: CurrentUser | None) -> dict: + """Minimal context every public template expects. `cu` is injected + into the template so the header CTA can flip between + 'Sign in / sign up' and 'Dashboard'.""" + return {"cu": cu} + + +@router.get("/pricing", response_class=HTMLResponse) +async def pricing_page( + request: Request, + cu: CurrentUser | None = Depends(maybe_current_user), +): + return templates.TemplateResponse(request, "pricing.html", _ctx(request, cu)) + + +@router.get("/about", response_class=HTMLResponse) +async def about_page( + request: Request, + cu: CurrentUser | None = Depends(maybe_current_user), +): + return templates.TemplateResponse(request, "about.html", _ctx(request, cu)) + + +@router.get("/terms", response_class=HTMLResponse) +async def terms_page( + request: Request, + cu: CurrentUser | None = Depends(maybe_current_user), +): + return templates.TemplateResponse(request, "terms.html", _ctx(request, cu)) + + +@router.get("/privacy", response_class=HTMLResponse) +async def privacy_page( + request: Request, + cu: CurrentUser | None = Depends(maybe_current_user), +): + return templates.TemplateResponse(request, "privacy.html", _ctx(request, cu)) + + +@router.get("/disclaimer", response_class=HTMLResponse) +async def disclaimer_page( + request: Request, + cu: CurrentUser | None = Depends(maybe_current_user), +): + return templates.TemplateResponse(request, "disclaimer.html", _ctx(request, cu)) diff --git a/app/services/openrouter.py b/app/services/openrouter.py index f1402f4..759e9f5 100644 --- a/app/services/openrouter.py +++ b/app/services/openrouter.py @@ -549,6 +549,13 @@ def _endpoint_for(provider: str) -> tuple[str, str, str, dict[str, str]]: # OpenRouter dashboard — keep aligned with the live brand. "HTTP-Referer": branding.SITE_URL, "X-Title": branding.BRAND_NAME, + # No-train opt-out. Tells OpenRouter (and any compatible + # upstream) that this request must not be used to train + # or improve models. The Privacy notice promises this; the + # header is what makes the promise truthful. If a future + # upstream ignores the header, fix the provider — not the + # header — so the contract stays auditable. + "X-OR-Allow-Training": "false", }, ) raise RuntimeError(f"Unknown LLM provider: {provider!r}") diff --git a/app/static/css/cassandra.css b/app/static/css/cassandra.css index 9bb7ffc..e5f1d79 100644 --- a/app/static/css/cassandra.css +++ b/app/static/css/cassandra.css @@ -1421,3 +1421,290 @@ details[open] .pf-analysis__head-left::before { content: "▾ "; } ::-webkit-scrollbar-track { background: var(--bg); } ::-webkit-scrollbar-thumb { background: var(--dim); border-radius: 0; } ::-webkit-scrollbar-thumb:hover { background: var(--muted); } + +/* ============================================================ + * Public pages — landing, pricing, about, terms, privacy, disclaimer. + * Shared by all templates extending public_base.html. Visual language + * matches the app shell (same palette, monospace brand, restrained + * typography) but without dashboard chrome. + * ============================================================ */ + +.public-page { background: var(--bg); } + +.public-shell { + display: flex; + flex-direction: column; + min-height: 100vh; + max-width: 1080px; + margin: 0 auto; + padding: 0 24px; +} + +.public-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 22px 0 16px; + border-bottom: 1px solid var(--border); +} +.public-header__brand { + color: var(--accent); + font-weight: 700; + text-decoration: none; + font-family: var(--font-mono); + font-size: 15px; + letter-spacing: 0.01em; +} +.public-header__brand::before { content: "▰ "; opacity: 0.6; } +.public-header__brand:hover { color: var(--text); } +.public-header__nav { display: flex; align-items: center; gap: 22px; } +.public-header__nav a { + color: var(--muted); + font-size: 13px; + text-decoration: none; +} +.public-header__nav a:hover, +.public-header__nav a.active { color: var(--text); } +.public-header__cta { + color: var(--accent) !important; + border: 1px solid var(--accent); + padding: 6px 14px; + border-radius: 3px; +} +.public-header__cta:hover { background: var(--accent); color: var(--bg) !important; } + +.public-main { + flex: 1; + padding: 48px 0 64px; +} + +.public-footer { + border-top: 1px solid var(--border); + padding: 28px 0 36px; + margin-top: 24px; + font-size: 12px; + color: var(--muted); +} +.public-footer__inner { + display: flex; + flex-direction: column; + gap: 14px; +} +.public-footer__brand strong { color: var(--text); margin-right: 10px; } +.public-footer__tagline { color: var(--muted); } +.public-footer__links { display: flex; flex-wrap: wrap; gap: 16px; } +.public-footer__links a { color: var(--muted); text-decoration: none; } +.public-footer__links a:hover { color: var(--accent); } +.public-footer__meta { color: var(--dim); font-size: 11px; } + +/* --- Hero (landing) -------------------------------------------------- */ + +.hero { + padding: 32px 0 48px; + border-bottom: 1px solid var(--border); + margin-bottom: 48px; +} +.hero__brand { + color: var(--muted); + font-family: var(--font-mono); + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.hero__headline { + font-size: clamp(28px, 5vw, 44px); + font-weight: 700; + line-height: 1.15; + color: var(--text); + margin: 12px 0 14px; + letter-spacing: -0.01em; +} +.hero__subhead { + font-size: 16px; + color: var(--muted); + max-width: 640px; + line-height: 1.55; + margin: 0 0 24px; +} +.hero__ctas { display: flex; flex-wrap: wrap; gap: 12px; } +.hero__ctas .btn-primary, +.hero__ctas .btn-secondary { + display: inline-block; + padding: 10px 22px; + border-radius: 3px; + font-size: 13.5px; + font-weight: 500; + text-decoration: none; +} +/* Qualify with `a` so we beat `a { color: var(--accent) }` and any + :link/:visited UA defaults. Without `a.btn-primary` the cascade can + resolve in favour of the visited-link color on some browsers and the + label disappears against the accent background. */ +a.btn-primary, +a.btn-primary:link, +a.btn-primary:visited { + background: var(--accent); + color: var(--bg); + border: 1px solid var(--accent); +} +a.btn-primary:hover { background: transparent; color: var(--accent); } +a.btn-secondary, +a.btn-secondary:link, +a.btn-secondary:visited { + background: transparent; + color: var(--text); + border: 1px solid var(--border); +} +a.btn-secondary:hover { color: var(--accent); border-color: var(--accent); } + +/* --- Feature blocks (landing) --------------------------------------- */ + +.feature-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 24px; + margin: 8px 0 56px; +} +.feature-card { + border: 1px solid var(--border); + border-radius: 4px; + padding: 22px 22px 24px; + background: var(--surface); +} +.feature-card__tag { + font-family: var(--font-mono); + font-size: 10.5px; + color: var(--accent); + letter-spacing: 0.08em; + text-transform: uppercase; + margin-bottom: 10px; +} +.feature-card__title { + font-size: 17px; + font-weight: 600; + color: var(--text); + margin: 0 0 10px; +} +.feature-card__body { + font-size: 13.5px; + line-height: 1.6; + color: var(--muted); + margin: 0; +} + +/* --- Section primitives reused across pricing/about/legal ---------- */ + +.public-section { + margin: 0 0 56px; +} +.public-section__head { + font-size: 20px; + font-weight: 600; + color: var(--text); + margin: 0 0 16px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border); +} +.public-section h3 { + font-size: 15px; + font-weight: 600; + color: var(--text); + margin: 24px 0 8px; +} +.public-section p, +.public-section li { + font-size: 14px; + line-height: 1.65; + color: var(--text); +} +.public-section p { margin: 0 0 14px; } +.public-section ul { + margin: 0 0 16px; + padding-left: 22px; +} +.public-section li { margin-bottom: 6px; } +.public-section a { color: var(--accent); } + +.public-section--callout { + border-left: 3px solid var(--accent); + padding: 16px 22px; + background: var(--surface); + border-radius: 0 4px 4px 0; + margin: 0 0 32px; +} +.public-section--warning { + border-left-color: var(--negative); + background: color-mix(in srgb, var(--negative) 6%, var(--bg)); +} +.public-section--warning a { color: var(--text); } + +/* --- "What this is not" strip on landing --------------------------- */ + +.not-strip { + border: 1px dashed var(--border); + padding: 18px 22px; + border-radius: 4px; + margin: 0 0 56px; + background: var(--surface); +} +.not-strip strong { color: var(--text); } +.not-strip ul { display: flex; flex-wrap: wrap; gap: 18px 28px; margin: 8px 0 0; padding: 0; list-style: none; } +.not-strip li { color: var(--muted); font-size: 13px; } +.not-strip li::before { content: "✕ "; color: var(--negative); font-weight: 700; margin-right: 4px; } + +/* --- Pricing comparison -------------------------------------------- */ + +.tier-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 18px; + margin: 8px 0 24px; +} +.tier-card { + border: 1px solid var(--border); + border-radius: 4px; + padding: 22px 22px 26px; + background: var(--surface); + display: flex; + flex-direction: column; +} +.tier-card--featured { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent) inset; +} +.tier-card__name { + font-family: var(--font-mono); + font-size: 11px; + color: var(--muted); + letter-spacing: 0.08em; + text-transform: uppercase; + margin-bottom: 8px; +} +.tier-card__price { + font-size: 22px; + font-weight: 700; + color: var(--text); + margin-bottom: 4px; +} +.tier-card__price-hint { + font-size: 12px; + color: var(--muted); + margin-bottom: 18px; +} +.tier-card ul { + list-style: none; + padding: 0; + margin: 0 0 22px; + flex: 1; +} +.tier-card li { + font-size: 13.5px; + color: var(--text); + padding: 6px 0; + border-bottom: 1px solid var(--border); +} +.tier-card li:last-child { border-bottom: 0; } +.tier-card li::before { content: "✓ "; color: var(--positive); font-weight: 700; margin-right: 4px; } +.tier-card li.tier-card__excluded { color: var(--muted); } +.tier-card li.tier-card__excluded::before { content: "✕ "; color: var(--dim); } +.tier-card__cta { margin-top: auto; } diff --git a/app/templates/about.html b/app/templates/about.html new file mode 100644 index 0000000..0f0d989 --- /dev/null +++ b/app/templates/about.html @@ -0,0 +1,76 @@ +{% extends "public_base.html" %} +{% block title %}{{ BRAND_NAME }} · About{% endblock %} + +{% block main %} + +
+

About {{ BRAND_NAME }}

+

+ {{ BRAND_NAME }} is a news aggregator with a macro brain. + We pull market headlines and a curated set of cross-asset signals, + auto-tag the news by theme, and use a large language model to write + a short interpretation every hour — in plain English, with a fixed + editorial discipline. +

+

+ Editorially we’re a media service, not a financial one. We + don’t make buy/sell calls, we don’t do technical + analysis, and we don’t pretend to know which way the tape goes + next. +

+
+ +
+

Who it’s for

+

+ Investors who’d rather understand than trade. People + who want a coherent read of the underlying fundamentals — what the + real economy, policy, and valuation are doing — not the next 30 + minutes of price action, not chart patterns, and not which ETF to + buy. +

+

+ Particularly: investors new enough to markets that the gambling + framing of social media is doing real damage. Every read here is + deliberately calm, anti-technical-analysis, and rooted in + fundamentals. We treat trading and gambling as different activities, + and we’re built for the people who already see them that way. +

+
+ +
+

How it’s built

+

+ Architecturally, the product is deliberately privacy-shaped: +

+
    +
  • Your portfolio lives in your browser. The server’s view is + an aggregate set of tickers held across the whole user base, + which on its own does not identify any individual user — see + the Privacy notice for the exact data + structures.
  • +
  • Cloud sync of your portfolio is opt-in and end-to-end encrypted + with a PIN only you know.
  • +
  • No third-party tracking, no analytics SDKs, no ad cookies.
  • +
+

+ Full details on the privacy page. +

+
+ +
+

+ {{ BRAND_NAME }} is operated from {{ OPERATOR_JURISDICTION }} by an + individual operator. It is not a regulated firm, and + nothing here is investment advice. See the + disclaimer. +

+

+ If you are in financial distress, please consider speaking to a + free service such as + MoneyHelper + before relying on anything you read here. +

+
+ +{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html index 9f08c7e..fa05eb4 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -167,7 +167,11 @@ {% if cu.user %} Settings {% endif %} - Logout + Pricing + Terms + Privacy + Disclaimer + Logout {% endif %} diff --git a/app/templates/disclaimer.html b/app/templates/disclaimer.html new file mode 100644 index 0000000..e070ba2 --- /dev/null +++ b/app/templates/disclaimer.html @@ -0,0 +1,110 @@ +{% extends "public_base.html" %} +{% block title %}{{ BRAND_NAME }} · Disclaimer{% endblock %} + +{% block main %} + +
+

+ Financial disclaimer +

+

+ {{ BRAND_NAME }} is not investment advice. +

+

+ This page is part of, and qualifies, the + Terms of Service. +

+
+ +
+

In short

+
    +
  • Everything published here is for educational and + informational purposes only.
  • +
  • Nothing on this site is a buy or sell recommendation, a personal + recommendation, or an inducement to deal in any financial + instrument.
  • +
  • {{ BRAND_NAME }} is not a regulated financial firm. It is not + authorised by the Financial Conduct Authority and is not a + registered investment adviser in any jurisdiction.
  • +
  • The output is not tailored to your circumstances, objectives, + tax position, or risk tolerance.
  • +
  • Past performance does not predict future returns. Investing + carries the risk of losing money, including the principal.
  • +
+
+ +
+

About the AI output

+

+ The strategic log, indicator summaries, and portfolio analysis are + generated by large language models from publicly available market + data and news. They can be wrong, incomplete, or out of date. Numbers + can be misread. Models occasionally generate inaccurate or invented + information (often called “hallucinations”). Treat them + as a prompt to think, not as facts to act on. +

+

+ The portfolio analysis is an interpretation of holdings you + supplied. It does not consider your overall wealth, debts, tax + position, or anything we don’t see. It is not personalised + advice. +

+
+ +
+

Before you act on anything

+

+ Consult a properly qualified, regulated financial adviser who knows + your full situation. Read the source documents (issuer accounts, + fund prospectus, etc.). If you are not in a position to lose the + money you would put at risk, do not put it at risk. +

+
+ +
+

Jurisdiction

+

+ {{ BRAND_NAME }} is operated from {{ OPERATOR_JURISDICTION }}. The + Service is not directed at, or intended for distribution to or use + by, any person in any jurisdiction where such distribution or use + would be contrary to local law or regulation. +

+

+ No part of this Service constitutes an offer or solicitation of + securities to any US person within the meaning of US securities law. +

+

+ The Service is not directed at retail or professional clients in any + EU/EEA member state, nor in any jurisdiction where its provision + would require local licensing or registration. Where any output of + the Service could be construed as an “investment + recommendation” under Regulation (EU) 596/2014 (Market Abuse + Regulation) or its UK equivalent, it is non-personalised, produced + by a non-regulated source for educational purposes only, and the + operator (a) has no position in, or remuneration linked to, the + specific instruments mentioned in any individual piece of commentary, + and (b) is not a “relevant person” within MAR Art. + 3(1)(34). +

+
+ +
+

No warranty

+

+ The service is provided “as is” without warranties of any + kind. To the maximum extent permitted by applicable law, the operator + excludes liability for any loss arising from use of, or reliance on, + the content. See the Terms of Service for the + full limitation of liability. +

+
+ +
+

+ Questions about this disclaimer: + {{ OPERATOR_EMAIL }}. +

+
+ +{% endblock %} diff --git a/app/templates/landing.html b/app/templates/landing.html new file mode 100644 index 0000000..e275377 --- /dev/null +++ b/app/templates/landing.html @@ -0,0 +1,103 @@ +{% extends "public_base.html" %} +{% block title %}{{ BRAND_NAME }} · {{ TAGLINE }}{% endblock %} + +{% block main %} + +
+
{{ BRAND_NAME }}
+

{{ TAGLINE }}

+

+ Built for investors who want to act rationally and + tune out the high-frequency noise that comes from treating markets + like a casino. We aggregate cross-asset news and macro signals, + then write a plain-English read of what the underlying fundamentals + justify versus what the crowd is doing. Refreshed hourly. A media + service, not a financial one. +

+
+ {% if cu and (cu.user or cu.is_admin) %} + Open dashboard + See pricing + {% else %} + Sign up free + See pricing + {% endif %} +
+
+ +
+
+
News, aggregated
+

Headlines from across the macro universe

+

+ RSS and per-ticker feeds covering equities, rates, credit, FX, + commodities, and geopolitics. Every headline is auto-tagged by + theme so the noise stays as noise and the fundamentals-relevant + stuff is easy to find. Ingestion follows the trading calendar — + off-hours stay quiet. +

+
+ +
+
Macro signals
+

A curated cross-asset tape

+

+ A hand-picked set of indicators across every asset class, refreshed + hourly during market hours. Each group gets a short read that + explains what the move means, not what it was. Anchored + in earnings, policy, valuation — not chart patterns. +

+
+ +
+
The hourly read
+

Rational vs irrational, every paragraph

+

+ We tie the day’s headlines and the cross-asset signals into + a single short interpretation. Each paragraph separates + rational drivers (earnings, policy, valuation) + from irrational ones (positioning, narrative, + flows) and names the gap. Two reading levels: novice and + intermediate. This is editorial commentary on public data — + not a forecast and not advice on any investment decision. +

+
+
+ +
+

+ Paid users can also drop a Trading 212 pie CSV for an AI + sense-check on concentration, regime fit, and currency exposure. + Holdings stay in your browser by default; opt in to encrypted cloud + sync to restore on another device. +

+
+ +
+ What this isn’t. +
    +
  • Not investment advice.
  • +
  • Not trading signals.
  • +
  • Not a day-trading tool.
  • +
  • No buy/sell calls, ever.
  • +
  • No chart-pattern predictions.
  • +
  • Not a regulated service.
  • +
+
+ +
+

+ By signing up you agree to our Terms and + Privacy notice, and confirm you’ve read + the financial disclaimer. +

+
+ {% if cu and (cu.user or cu.is_admin) %} + Open dashboard + {% else %} + Sign up free + {% endif %} +
+
+ +{% endblock %} diff --git a/app/templates/login.html b/app/templates/login.html index 7349631..66816c1 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -41,6 +41,13 @@ + + diff --git a/app/templates/pricing.html b/app/templates/pricing.html new file mode 100644 index 0000000..8d88352 --- /dev/null +++ b/app/templates/pricing.html @@ -0,0 +1,96 @@ +{% extends "public_base.html" %} +{% block title %}{{ BRAND_NAME }} · Pricing{% endblock %} + +{% block main %} + +
+

Pricing

+

+ Two tiers. The news aggregator and the hourly macro interpretation + are free for everyone — we want the read out where people can use + it. The paid tier extends the same editorial commentary to the + specific tickers in a portfolio you upload — an educational read + of public data, not advice on whether to hold them. +

+
+ +
+ +
+
Free
+
£0
+
Forever. No card needed.
+
    +
  • News aggregator — auto-tagged by theme
  • +
  • Cross-asset macro signals across every asset class
  • +
  • Hourly AI interpretation of the news + the tape
  • +
  • Per-group cross-asset summaries
  • +
  • Novice / Intermediate reading levels
  • +
  • Portfolio import & analysis
  • +
  • Encrypted cloud sync
  • +
+
+ {% if cu and (cu.user or cu.is_admin) %} + Open dashboard + {% else %} + Sign up free + {% endif %} +
+
+ + + +
+ +
+

How the data is handled

+

+ Your portfolio holdings live in your browser’s local storage by + default. The server only learns which Yahoo tickers appear across the + user base — an anonymous union, with no link back to any specific + user. +

+

+ If you opt in to encrypted cloud sync, your pie is + encrypted in your browser with a PIN you choose, then sent to the + server. We add a second layer of encryption with a key only the + server holds. We never see your holdings as plaintext, and forgetting + the PIN means we can’t recover it for you. Full details on the + privacy page. +

+
+ +
+

+ Not investment advice. Every output here is an + interpretation of public data — not personalised advice, not a + recommendation, and not produced by a regulated entity. Read the full + disclaimer before relying on anything you see. +

+
+ +{% endblock %} diff --git a/app/templates/privacy.html b/app/templates/privacy.html new file mode 100644 index 0000000..736af55 --- /dev/null +++ b/app/templates/privacy.html @@ -0,0 +1,311 @@ +{% extends "public_base.html" %} +{% block title %}{{ BRAND_NAME }} · Privacy{% endblock %} + +{% block main %} + +
+

Privacy notice

+

+ Last updated: 2026-05-24. The operator (data controller) is + {{ LEGAL_OPERATOR }}, {{ OPERATOR_JURISDICTION }}. Registered with + the UK Information Commissioner’s Office under reference + ZC098928. Questions: + {{ OPERATOR_EMAIL }}. +

+

+ This page describes exactly what we collect, what we don’t, + where it lives, and how long we keep it. It is written from the code, + not from a template — every claim corresponds to an explicit + code path we’re happy to point a reviewer at. +

+
+ +
+

What we collect

+
    +
  • + Your email address, when you sign in. We use it + only to send one-time login codes. +
  • +
  • + An argon2 hash of each login code, plus expiry + and attempt counts. The plaintext code is sent to your inbox and + never written to disk on our side. +
  • +
  • + A signed session cookie after you verify a code. + It contains your user id only and is signed so we can detect + tampering. Cookie is marked Secure and HttpOnly. +
  • +
  • + Anonymous ticker universe: when you upload a + portfolio CSV we record which Yahoo tickers appear, with + no link to your account. The same row would exist whether + any specific user holds the ticker or not — once a ticker is in + the universe, the row carries no signal as to whose import added it. +
  • +
  • + If you opt in to encrypted cloud sync: an opaque + blob of bytes per user. The blob is your portfolio, encrypted in + your browser with a PIN you choose, then wrapped a second time on + the server with a key only the server holds. We can’t decrypt + the blob to plaintext without your PIN, and we can’t recover + your PIN if you forget it. By enabling cloud sync you give your + consent (UK-GDPR Art. 6(1)(a)) to this processing; you can + withdraw consent at any time by disabling sync in Settings, which + also removes the server-side blob. +
  • +
  • + Anonymised cost ledger of AI calls (model, tokens, + cost). No portfolio or personal data is attached to ledger rows. +
  • +
  • + Referral linkage: if you signed up via an invite + link, we record which existing user’s code you used so we can + apply the agreed referral credit later. +
  • +
  • + Job-run telemetry: success/failure timestamps for + the scheduled jobs that fetch market data and generate AI reads. + No user identifiers are attached. +
  • +
+
+ +
+

What we don’t collect

+
    +
  • + Your portfolio holdings as plaintext on the server. + Parsed pies are returned to your browser and kept in + localStorage. The server’s view is the anonymous + ticker universe described above. +
  • +
  • + Third-party analytics or ad cookies. No Google + Analytics, no Hotjar, no Segment, no Facebook pixel, no LinkedIn + tag. (You can verify by viewing-source on any page.) +
  • +
  • + Browser fingerprints. +
  • +
  • + IP-address joins to your user identity. IP + addresses are processed transiently by the reverse proxy for + security and access logging, retained for up to 30 days, and not + linked to your account record. +
  • +
+
+ +
+

Lawful basis (UK-GDPR Art. 6)

+

We rely on the following lawful bases:

+
    +
  • + Performance of a contract (Art. 6(1)(b)) — for + operating your account, the sign-in flow, paid features, and the + mechanics of encrypted cloud sync. +
  • +
  • + Legitimate interests (Art. 6(1)(f)) — for the + anonymous ticker universe, the anonymised cost ledger, job-run + telemetry, and reverse-proxy access logs. Our interest is the + secure, abuse-resistant, cost-controlled operation of a free + public service, balanced against the minimal and de-identified + nature of the data. +
  • +
  • + Consent (Art. 6(1)(a)) — where you opt in to + encrypted cloud sync (and the related caching of a derived + encryption key in your browser’s sessionStorage). + You can withdraw consent at any time by disabling sync in + Settings; the cached key is cleared and the server-side blob is + removed. +
  • +
+
+ +
+

Automated decisions and profiling

+

+ The Service does not make decisions about you that produce legal or + similarly significant effects in an automated way (UK-GDPR Art. 22). + The AI portfolio analysis is editorial commentary on the holdings + you upload; it does not approve, reject or rank you, and you remain + the sole decision-maker about anything in your account. +

+
+ +
+

Cookies and local storage

+
    +
  • + Session cookie — strictly necessary for keeping + you signed in (PECR reg. 6(4)). No prior consent required. +
  • +
  • + Local preferences — your chosen theme (light / + dark) and reading level (Novice / Intermediate) are stored in + localStorage on your device. They never leave the + browser. +
  • +
  • + Local portfolio + cached sync key — parsed pies + live in localStorage on your device. If you enable + cloud sync, the derived encryption key is cached in + sessionStorage so you don’t have to re-enter + your PIN on every navigation. This caching is performed only with + your consent (given when you enable sync); it is cleared when you + close the tab or disable sync. +
  • +
+
+ +
+

Where the data lives, and international transfers

+

+ The server runs in {{ OPERATOR_JURISDICTION }}. Data is stored in a + MariaDB database on the same host, backed up locally. +

+

+ Two flows can take personal data outside the UK: +

+
    +
  • + SMTP for sending one-time login codes. Operator-hosted, + currently inside the UK; if that changes we will update this notice. +
  • +
  • + AI provider calls for the strategic log, indicator + summaries, and (paid) portfolio analysis. Where the provider sits + outside the UK, we rely on the UK International Data Transfer + Agreement (IDTA) / the UK Addendum to the EU Standard Contractual + Clauses where no adequacy decision applies. Each outbound request + carries an explicit no-training opt-out header + (X-OR-Allow-Training: false on OpenRouter); see the + Third parties section below for the caveats. +
  • +
+
+ +
+

Retention

+
    +
  • + Login codes: expire after a few minutes; row + remains briefly to enforce single-use, then is purged. +
  • +
  • + Session cookies: expire automatically; you can + sign out at any time to revoke. +
  • +
  • + Ticker universe: rows untouched for 60 days are + evicted by a nightly job. Active tickers remain. +
  • +
  • + Encrypted portfolio blob: kept until you disable + cloud sync (one click in Settings) or delete your account. We hold + one row per user; new uploads overwrite the previous blob. +
  • +
  • + Account: held until you ask us to delete it. + Email {{ OPERATOR_EMAIL }}. +
  • +
  • + Cost ledger and job telemetry: retained for + operational accounting; no personal data attached. +
  • +
+
+ +
+

Third parties

+
    +
  • + SMTP provider: an operator-hosted Mailu server + sends the one-time login codes. The provider sees your email + address and the code body (the code itself). +
  • +
  • + AI provider(s): DeepSeek (primary) with OpenRouter + as a fallback. They see the prompt for the strategic log, the + indicator summaries, and the portfolio analysis call — which + contains your holdings only when you press + “Generate AI analysis” on a paid plan, and only for the + duration of that single call. The portfolio analysis output is not + persisted on the server. +
    + No-training opt-out. Every OpenRouter request + carries the X-OR-Allow-Training: false header, which + signals to OpenRouter and any compatible upstream that the prompt + must not be used to train or improve models. DeepSeek does not + currently expose a per-request opt-out; if you do not want your + holdings to leave our server at all, do not use the AI portfolio + analysis feature. We do not control retention or training policies + on the provider side beyond the headers we set — the provider’s + own published data policy is the binding statement on that point. +
  • +
  • + Market-data sources: Yahoo Finance and a small set + of public RSS feeds. We request prices and headlines; we don’t + send them any user identifier. +
  • +
+
+ +
+

Your rights (UK-GDPR)

+

You have the right to:

+
    +
  • Ask what personal data we hold about you (Art. 15, right of access).
  • +
  • Have inaccurate data corrected (Art. 16, rectification).
  • +
  • Have your account and associated data deleted (Art. 17, erasure).
  • +
  • Export the data you can recognise (Art. 20, portability): your + email, any active encrypted blob, your referral linkage.
  • +
  • Restrict processing (Art. 18).
  • +
  • Object specifically to processing carried out on the basis of + legitimate interests (Art. 21), including any direct marketing.
  • +
  • Withdraw consent at any time where processing is based on + consent (Art. 7(3)), e.g. by disabling cloud sync.
  • +
  • Lodge a complaint with the + Information Commissioner’s Office + if you think we’re mishandling your data.
  • +
+

+ Email {{ OPERATOR_EMAIL }} to + exercise any of these. +

+
+ +
+

Children

+

+ The Service is not directed at, and is not intended for use by, + anyone under 18. Do not create an account if you are under 18. If + you believe a child has provided personal data to us, contact + {{ OPERATOR_EMAIL }} and we + will delete it. +

+
+ +
+

Security incidents

+

+ If we discover a personal-data breach likely to result in a risk to + your rights and freedoms, we will notify the ICO within 72 hours of + becoming aware of it, as required by UK-GDPR Art. 33, and notify + affected users without undue delay where Art. 34 requires. +

+
+ +
+

Changes to this notice

+

+ Material changes will be flagged in-app and dated above. Trivial + edits (grammar, restructuring) won’t. +

+
+ +{% endblock %} diff --git a/app/templates/public_base.html b/app/templates/public_base.html new file mode 100644 index 0000000..77e4186 --- /dev/null +++ b/app/templates/public_base.html @@ -0,0 +1,64 @@ + + + + + + {% block title %}{{ BRAND_NAME }}{% endblock %} + + {# Same flash-prevention theme bootstrap as the app shell. #} + + + + +
+
+ + {{ BRAND_NAME }} + + +
+ +
+ {% block main %}{% endblock %} +
+ +
+ +
+
+ + diff --git a/app/templates/terms.html b/app/templates/terms.html new file mode 100644 index 0000000..cee279e --- /dev/null +++ b/app/templates/terms.html @@ -0,0 +1,214 @@ +{% extends "public_base.html" %} +{% block title %}{{ BRAND_NAME }} · Terms of Service{% endblock %} + +{% block main %} + +
+

Terms of Service

+

+ Last updated: 2026-05-24. {{ LEGAL_OPERATOR }} operates {{ BRAND_NAME }} + (the “Service”) from {{ OPERATOR_JURISDICTION }}. +

+
+ +
+

1. Acceptance

+

+ By creating an account or otherwise using the Service you agree to + these Terms and to the Privacy notice and + Disclaimer. If you do not agree, do not + use the Service. +

+
+ +
+

2. The Service

+

+ {{ BRAND_NAME }} provides a macro-strategy dashboard with curated + market data, news, and AI-generated commentary. Paid features include + portfolio import, AI portfolio analysis, and optional end-to-end + encrypted cloud sync of your portfolio. Feature lists, tiers, and + pricing are described on the Pricing page and + may change over time. +

+

+ Nothing produced by the Service is investment advice. See the + Disclaimer for the full position. +

+
+ +
+

3. Accounts & authentication

+
    +
  • Accounts are tied to a working email address. Authentication is + by one-time codes sent to that address — no passwords.
  • +
  • You are responsible for the security of the email inbox you sign + up with. Anyone with access to that inbox can sign in.
  • +
  • One account per person. Do not share accounts or sign in on + behalf of someone else without their explicit permission.
  • +
  • You may delete your account at any time by writing to + {{ OPERATOR_EMAIL }}.
  • +
+
+ +
+

4. Acceptable use

+

You agree not to:

+
    +
  • Use automated tools to scrape, mirror, or repackage the Service + or any of its data, including market data and AI-generated + commentary, without prior written permission.
  • +
  • Use the Service, or any of its output, to ingest, train, fine-tune + or evaluate any machine-learning model without prior written + permission.
  • +
  • Abuse the one-time-code (OTP) system, including by submitting + emails you do not control.
  • +
  • Attempt to bypass authentication, paid-tier gating, or rate + limits.
  • +
  • Use the Service in any way that could damage, disable, + overburden, or impair it.
  • +
  • Use the Service to harass, defame, or threaten any person.
  • +
  • Reverse engineer, decompile, or otherwise extract source code, + except where applicable law expressly permits it.
  • +
  • Use the Service in a way that is unlawful in your jurisdiction.
  • +
+
+ +
+

5. Paid plans

+

+ If and when paid plans become available, you will be told the + applicable fees at point of sale. Paid features remain active for as + long as the subscription is current or any time-bounded credit + granted to your account is still valid. You can cancel a paid + subscription at any time; cancellation takes effect at the end of + the current billing period unless otherwise stated. +

+

+ Where the law gives you a 14-day right to cancel a subscription + (Consumer Contracts (Information, Cancellation and Additional + Charges) Regulations 2013, UK), that right applies. By starting to + use a paid feature immediately on purchase you agree we may begin + supplying the service within the cancellation period, and you + acknowledge that you lose the right to cancel in respect of any + digital content already delivered. +

+
+ +
+

6. Service availability

+

+ The Service is provided on a best-effort basis. There is no service + level agreement: outages, data delays, and feature changes may occur + without notice. Scheduled and unscheduled downtime is possible. +

+
+ +
+

7. Content & ownership

+

+ The Service’s code, design, indicator selection, and prompts + are owned or licensed by {{ LEGAL_OPERATOR }}. To the extent any + copyright subsists in the AI-generated commentary under UK law, it + vests in or is licensed to {{ LEGAL_OPERATOR }}. Market data is + sourced from third parties (e.g. Yahoo Finance and public RSS + feeds); their respective rights apply. You may view, save, and + quote short excerpts for personal use; you may not republish or + redistribute substantial portions, or use the Service’s + output to train machine-learning models, without prior written + permission. +

+

+ Any portfolio you upload remains your data. The Service does not + persist your holdings as plaintext (see the + Privacy notice). +

+
+ +
+

8. Suspension & termination

+

+ We may suspend or terminate access without notice for violation of + these Terms or for activity that risks the integrity, security, or + legality of the Service. You may stop using the Service at any time. +

+

+ For paid subscribers, suspension for suspected breach will, where + reasonably practicable, be preceded by notice and an opportunity to + respond, unless immediate suspension is necessary to protect the + Service, its users, or any third party. If we terminate a paid + subscription for a breach you did not cause, we will refund the + unused portion of any prepaid fees on a pro-rata basis. +

+
+ +
+

9. No warranty

+

+ The Service is provided “as is” and “as + available”, without warranties of any kind, express or implied, + including merchantability, fitness for a particular purpose, accuracy, + and non-infringement, to the maximum extent permitted by law. +

+
+ +
+

10. Limitation of liability

+

+ To the maximum extent permitted by law, {{ LEGAL_OPERATOR }} is not + liable for any indirect, incidental, special, consequential, or + punitive damages, or for any investment loss or missed gain, arising + from your use of the Service. Subject to the carve-outs below, the + total aggregate liability of {{ LEGAL_OPERATOR }} to you for any + claim is capped at the greater of (a) the fees you have paid in the + 12 months immediately preceding the claim, or (b) GBP 50. +

+

+ Nothing in these Terms limits or excludes liability that cannot + lawfully be limited or excluded, including (i) death or personal + injury caused by negligence, (ii) fraud or fraudulent + misrepresentation, (iii) for consumers, any statutory rights under + Part 1 of the Consumer Rights Act 2015 (including the right to + services performed with reasonable care and skill), and (iv) any + other liability that cannot be excluded under applicable mandatory + law in your country of residence. +

+

+ If you are a consumer, the cap in the first paragraph of this + clause does not apply to claims for direct losses arising from a + breach of those statutory rights, which remain at the level the law + requires. +

+
+ +
+

11. Changes

+

+ These Terms may change. Material changes will be flagged in-app or + by email. Continued use after a change means you accept the updated + Terms. If you do not accept a material change, you may close your + account before the change takes effect; we will refund any prepaid + fees for the unused period. +

+
+ +
+

12. Governing law and jurisdiction

+

+ These Terms are governed by the laws of England and Wales. Subject + to any mandatory law of the consumer’s country of residence, + the courts of England and Wales have non-exclusive + jurisdiction over any dispute. UK consumers may also bring + proceedings in the courts of the part of the United Kingdom in + which they are habitually resident. +

+
+ +
+

13. Contact

+

+ {{ OPERATOR_EMAIL }} +

+
+ +{% endblock %} diff --git a/app/templates_env.py b/app/templates_env.py index 91d17cd..fc92112 100644 --- a/app/templates_env.py +++ b/app/templates_env.py @@ -71,3 +71,7 @@ templates.env.globals["BRAND_NAME"] = branding.BRAND_NAME templates.env.globals["BRAND_SHORT"] = branding.BRAND_SHORT templates.env.globals["SITE_URL"] = branding.SITE_URL templates.env.globals["APP_URL"] = branding.APP_URL +templates.env.globals["TAGLINE"] = branding.TAGLINE +templates.env.globals["LEGAL_OPERATOR"] = branding.LEGAL_OPERATOR +templates.env.globals["OPERATOR_EMAIL"] = branding.OPERATOR_EMAIL +templates.env.globals["OPERATOR_JURISDICTION"] = branding.OPERATOR_JURISDICTION