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
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 .-> URL2The 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.
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)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.
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]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.
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 --> CTASchema 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.
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]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.