ReviewOS

stacks/reportshq

Public sharing and embeds

#16
Open chris opened this 8 days ago ·
8 days ago

Context

Public, read-only distribution: shareable report links and iframe embeds, driven by ReportShare tokens (#9). SSR matters here - a shared report is a marketing artifact and must render fully server-side (#11 components are SSR-safe by design).

Scope

  • Share links: /s/{token} renders the published report read-only - no auth, no project chrome, branded footer ("Made with ReportsHQ" linking home; removal is a Pro perk via #18). Tokens revocable and optionally expiring; rotating a token kills old links.
  • Scope control: a share exposes exactly the published snapshot with its default range; viewer-side range/filter changes are allowed but bounded to the report's own events (re-query through #10 with the share token as the authz principal - a dedicated middleware, never the bearer path).
  • Embeds: /embed/{token} variant with minimal chrome sized for iframes; X-Frame-Options/CSP set correctly for embed-only routes while the rest of the app stays deny; a copy-paste embed snippet in the share UI.
  • Rate limiting + caching on public routes (short-TTL cache keyed on token + range via @stacksjs/cache; the public path must not let one viral link melt the query engine, #10).
  • No leakage: shares never expose project metadata, member info, ingest keys, raw event properties beyond what the published blocks aggregate; assert this in tests, not just review.
  • Share management UI on the report (#13): create/copy/revoke, expiry, per-share view counter (best-effort, meterable in #18).
  • OG/social meta on share pages (title, description, and an SSR-rendered preview image can be a follow-up; at minimum correct text meta).
  • Feature tests: revocation immediacy, expiry, scope bounding, leakage assertions, embed headers.

Acceptance

  • A share link renders the seeded report in a logged-out browser; revoking it 404s within one request; the embed snippet works from a plain HTML file.

Depends on #9, #10, #13, #18 (gating). Part of #1 (M7 Launch). Ground rules: see #1.

8 days ago

Most of this is built in 8505add. CI green, 23 new tests. Leaving open for the pieces listed at the end.

Share links. /s?t={token} renders the published snapshot for somebody with no account: no auth guard, no app chrome, no bounce to /login. A token buys one report published snapshot and nothing around it - not the project, not its other reports, not its members, and never its ingest key.

That is enforced by shape rather than by remembering to check. shareByToken() returns a flat object with eight known fields, and a test asserts the exact key set, because a caller handed the project row would eventually render something from it and the ingest key lives on that row. Two more tests assert the serialised result contains no ingest key and no project name. The issue asked for leakage to be asserted rather than reviewed, and that is what those three do.

One answer for revoked, expired, unpublished, deleted and never-existed. Telling them apart tells somebody whether they once had access. Unpublishing a report kills its links with nobody having to remember to revoke them, because pulling a report back into draft is withdrawing it and the link should agree.

Revocation is immediate, verified over HTTP: the page shows "not available" on the very next request, because shareByToken reads revoked_at every time rather than caching the answer. Rotating a token breaks the old URL the same way, which is the point - rotating is what somebody does when a link has gone somewhere it should not have.

Gated by plan (#18): sharing is a capability check, the number of live links is a meter, and both answer 402 naming the tier that would lift them. Branding removal is checked against the plan rather than taken from the request body, or a Pro perk would be a matter of sending different JSON.

Embeds at /embed?t={token} serve the same data with the heading, range picker and page margins dropped, since they render inside somebody else layout.

Verified with no cookie at all: seven blocks with real numbers, no project name, no ingest key, no auth bounce.

Not done.

  • The framing headers, and this one is blocked upstream. The framework applies security headers to API routes but not to server-rendered views, so today nothing in the app sends X-Frame-Options - which means embeds work, and the "everything else stays deny" half does not. It cannot be fixed in userland: X-Frame-Options has no meta equivalent and CSP frame-ancestors is explicitly ignored in a meta tag. Filed as stacksjs/stacks#2325. The alternative is setting headers at the edge, which lands with #21.
  • Share management UI on the report page. The routes exist (/api/reports/shares, /shares/create, /shares/revoke, /shares/rotate) and are tested; nothing renders them yet.
  • Short-TTL caching on the public path. The query engine already caches per project and query (#10), so a viral link does not hit the database per view, but there is no cache keyed on token and range specifically.
  • OG/social meta on the share page.
8 days ago

Share management shipped in a622738. Create, copy, rotate and revoke now live on the report page for anyone who can administer the project, listing each link with its view count and expiry.

Rotating and revoking have no confirmation dialog, deliberately: both are visible in the list immediately and a new link is one click away, whereas a confirm on a reversible action trains people to dismiss the ones that matter. Copy falls back to a prompt where the clipboard API is unavailable, which is every local install over plain HTTP, rather than being a button that silently does nothing.

Verified in a browser: created a link, read back its URL, and confirmed it appears with its view count.

Still open here: the framing headers, still blocked on stacksjs/stacks#2325, plus OG/social meta and a short-TTL cache keyed on token and range.

Sign in to comment on this issue.