GMI · TECHNOLOGY OBSERVATORY // ALL SYSTEMS NOMINAL
ENGINEERED BY LEOPARD DATA

Shared Reports Architecture

How GMI turns a private, login-gated AI report into a link that unfurls into a rich preview card in iMessage — with no IDs in the URL, no WASM boot for the viewer, and licensing-grade viewer accounting

END-TO-END FLOW
rendering diagram…
flowchart TD
    subgraph Owner[Owner - authenticated]
      BTN[Share button<br/>web, mobile] --> CREATE[Create share link<br/>ownership + completed checks]
      CREATE --> SL[(sharelinks<br/>CSPRNG token, revocation,<br/>expiry, view count)]
    end
    subgraph Viewer[Anonymous viewer]
      URL[Tokened URL pasted anywhere] --> SHIM[Server-rendered shim<br/>OG tags + level-1 HTML]
      URL2[SPA route /shared/token] --> JSON[Anonymous JSON endpoint]
    end
    SHIM --> RES[Token resolver]
    JSON --> RES
    RES --> SL
    RES --> BLOB[(Pre-computed report blob<br/>dashboard_summary.json)]
    SHIM -. open interactive report .-> URL2
The owner mints an opaque token; anonymous viewers resolve it server-side. The report content itself is a single pre-computed JSON blob — sharing adds zero recomputation.

The Token Is the Whole URL Story

A share link carries exactly one piece of information: a 32-byte CSPRNG token, base64url-encoded to 43 characters. Account IDs and job IDs never appear in any public URL — the server resolves token → (owner, report) on every request. That single design choice eliminates the entire IDOR class for this surface: there is nothing to enumerate, nothing to increment, and nothing to guess (2256 is not a brute-forceable space).

Links are rows, not derived values: a sharelinks table stores the token alongside creation, optional expiry, and revocation timestamps. Revoking is an UPDATE, and the public page dies instantly for everyone holding the URL. Creating a link is idempotent — one live link per report, so re-clicking Share can never spawn a pile of orphaned URLs.

LINK PREVIEW MECHANICS
rendering diagram…
sequenceDiagram
    participant S as Sender's phone
    participant M as Messaging service
    participant F as GMI shim (Functions)
    participant R as Recipient
    S->>F: GET /shared/token (paste triggers unfurl)
    F-->>S: HTML + og:title / og:description / og:image
    S->>F: GET /shared/token/card.png
    F-->>S: 1200x630 PNG (grade, tickers, KPIs)
    S->>M: message + preview card attached
    M->>R: card renders in the thread
    R->>F: tap -> GET /shared/token
    F-->>R: full level-1 summary as static HTML
    R->>F: optional: open interactive report (SPA)
Preview bots never execute JavaScript — the share URL must be real server-rendered HTML. The sender's own device fetches the preview in iMessage, so the card generates while the report definitely exists.

Why the Share URL Is Not the SPA

GMI's client app is Blazor WebAssembly behind a static host — great for the logged-in experience, hostile to link previews. A messaging app's unfurler does one plain HTTP GET and reads <meta> tags from raw HTML; it will never boot a multi-megabyte WASM runtime. So the share URL points at a server-rendered shim: a small Azure Function that returns complete HTML with per-report Open Graph tags and the level-1 summary baked in as static markup. A human on a phone sees the full graded summary in one round trip — grades, KPIs, sector chips, AI insights — before any JavaScript exists in the page at all. A "open the interactive report" link hands off to the SPA route for viewers who want the richer client.

This is the same trick GMI's SEO ticker pages use at build time, applied at request time. The shim caches for five minutes and carries X-Robots-Tag: noindex — tokened URLs are unlisted by design, not an SEO surface.

The Preview Card Is Generated, Not Static

The og:image is a 1200×630 PNG rendered per report, on demand, with pure-managed ImageSharp — no native graphics stack, which matters on a Linux Functions plan with no system fonts (the Inter typeface ships with the app under its open license). The card carries real data: the portfolio letter grade (per-symbol AI grades averaged on a GPA-style 0–12 scale), ticker chips with each stock's grade and color, and a KPI strip — combined market cap, average P/E, dividend yield, analyst buy percentage. In a group chat, the card is the pitch; a generic logo card would waste the feature's single highest-leverage pixel real estate.

Cards cache for an hour and return 404 for revoked links — a dead link doesn't keep a live thumbnail.

REPORT PARITY
rendering diagram…
flowchart TD
    subgraph Component[ONE dashboard component - unchanged call sites]
      DASH[AI Report Dashboard<br/>KPIs, tabs, charts, drill-downs]
      PANES[Child tab panes<br/>fetch their own data]
    end
    DASH --> SVC{Dispatching data service}
    PANES --> SVC
    SVC -- owner session --> AUTH[Authenticated endpoints<br/>account + job scoped]
    SVC -- share token set --> TOK[Anonymous token endpoints<br/>summary, symbol detail,<br/>analysis tab feeds]
    TOK --> RES[One resolver:<br/>revoked / expired / disabled<br/>owner checks on every read]
    AUTH --> DATA[(Same blobs, same DTOs,<br/>same rendering)]
    RES --> DATA
    SVC -. shared mode: Ask-Claude and<br/>Share actions dead-ended .-> X[Never public]
The shared view IS the AI Report Dashboard — the same component, verbatim, with the data plane swapped underneath it. One dispatching service reroutes every read through token-scoped endpoints; the component's call sites never change.

Report Parity: the Shared View Is the Real Dashboard

A shared report doesn't get a watered-down "summary page" — the recipient sees the AI Report Dashboard itself, verbatim: the same KPIs, sector breakdown, AI insights, ML health scores, portfolio projection charts, every analysis tab, and full symbol drill-downs. That's the demo working at full strength; a lesser copy would undersell the product at exactly the moment a curious stranger is judging it.

The engineering trick is that parity is achieved with zero duplicated markup. The dashboard component renders in a "shared mode" where a scoped context carries the share token, and a dispatching implementation of the dashboard's data service reroutes every read to token-scoped anonymous endpoints — same DTOs, same rendering paths, owner's data resolved server-side from the token. The component's hundreds of data-call sites are untouched; child panes that fetch their own tab data inherit the rerouting automatically through dependency injection. Owner-only affordances (the Share panel, Ask-Claude) hide themselves, replaced by a free-tier call-to-action. One codebase, one dashboard, two audiences.

RESOLUTION STATE MACHINE
rendering diagram…
flowchart TD
    T[Incoming token] --> KNOWN{Token exists?}
    KNOWN -- no --> NF[404 page<br/>link never existed]
    KNOWN -- yes --> LIVE{Revoked or expired?}
    LIVE -- yes --> GONE[410 page<br/>'no longer available']
    LIVE -- no --> CONTENT{Report blob still exists?}
    CONTENT -- no --> GONE
    CONTENT -- yes --> VER{SchemaVersion in<br/>supported window?}
    VER -- no --> INC[422 page<br/>'ask the sender for a fresh report']
    VER -- yes --> AGE{Older than 90 days?}
    AGE -- yes --> STALE[Render + dated<br/>staleness banner]
    AGE -- no --> OK[Render summary]
    NF --> CTA[Every terminal page carries<br/>the free-tier signup CTA]
    GONE --> CTA
    INC --> CTA
Every terminal state is a designed page, never a broken render. Failure copy points the viewer at the sender or the free tier — an anonymous viewer can't 'log in' or 'upgrade'.

Schema Versioning: Links Outlive App Versions

A report is a frozen JSON blob written at generation time, and a shared link extends that blob's audience years past the deploy that wrote it. Every summary now carries a SchemaVersion stamp; the renderer supports a version window (blobs from before versioning read as version 0 and stay renderable). A blob outside the window — too old after a deliberate floor bump, or written by a newer build than the one serving it — renders a graceful "this report is no longer compatible, ask the sender for a fresh one" page instead of a half-broken dashboard. Deserialization failure hits the same path, so even an unforeseen breaking change degrades to designed copy with a signup CTA.

Two sibling states complete the machine: content deleted by retention/purge machinery returns HTTP 410 with a "no longer available" page, and reports older than 90 days render normally under a dated staleness banner — grades drift with markets, and a months-old A- deserves a caveat, not a tombstone.

VIEWER ACCOUNTING
rendering diagram…
flowchart LR
    V[Anonymous page view] --> BOT{Preview-bot<br/>user agent?}
    BOT -- yes --> SKIP[Serve page,<br/>count nothing]
    BOT -- no --> HASH[SHA-256 of viewer IP<br/>raw IP never stored]
    HASH --> INS[Insert into dedupe table<br/>unique key: link + hash + UTC day]
    INS -- duplicate --> NOOP[Same viewer today<br/>rejected write, no count]
    INS -- new row --> CNT[Atomic view-count increment]
    CNT --> STATS[Admin stats:<br/>distinct viewers per month,<br/>3-month average]
    STATS --> TIER[Data-license End User tier<br/>threshold alerts before breach]
A unique-key dedupe table makes view counting idempotent per viewer per day, filters preview bots, and feeds the data-licensing End User tier stats — one query answers the vendor audit.

Counting Viewers Like a Data Vendor Audit Is Coming

Market-data licenses count "end users" — unique individuals who see the data, including anonymous ones. GMI's counting is designed to be defensible: each successful view inserts into a dedupe table keyed (link, SHA-256 of viewer IP, UTC date). The unique index makes the insert idempotent — refreshes, re-opens, and prefetches collapse to one row — and a duplicate insert costs one rejected write, so the public endpoint can't be used to write-amplify the database. Raw IPs are never stored. Preview bots (iMessage, Slack, WhatsApp, crawlers) are excluded by user-agent before counting: a bot fetch is not a human viewer.

The admin dashboard rolls this up into distinct viewers per calendar month and the trailing three-month average — exactly the number the license tier is measured on — with thresholds highlighted before they're crossed. Compliance as a query, not a scramble.

The Anonymous Surface Is a Deliberate Hole, Fenced

Every other report endpoint sits behind bearer-token auth plus per-function ownership checks. The share endpoints — summary, shim page, card image, and the token-scoped data feeds behind the verbatim dashboard — are opted into a small public-function allowlist in the auth middleware — the same mechanism that serves the public blog — and inherit the anonymous per-IP rate limit bucket automatically. Every anonymous feed funnels through one resolver applying every liveness rule (unknown, revoked, expired, disabled owner), and all of it is read-only. The single hard exclusion is Ask-Claude, which bills the report owner's account and is disabled at three layers: hidden in the shared UI, dead-ended in the client's dispatching service, and simply never exposed as a token endpoint.

Free-tier accounts share on equal footing with paid ones — deliberately. A shared report is the product demonstrating itself; the recipient's path from "nice analysis" to their own free account is one tap on the card that arrived in the group chat.