Files
worldmonitor/server/_shared/brief-render.d.ts
Elie Habib 55ac431c3f feat(brief): public share mirror + in-magazine Share button (#3183)
* feat(brief): public share mirror + in-magazine Share button

Adds the growth-vector piece listed under Future Considerations in the
original brief plan (line 399): a shareable public URL and a one-click
Share button on the reader's magazine.

Problem: the per-user magazine at /api/brief/{userId}/{issueDate} is
HMAC-signed to a specific reader. You cannot share the URL you are
looking at, because the recipient either 403s (bad token) or reads
your personalised issue against your userId. Result: no way to share
the daily brief, no way for readers to drive discovery. Opening a
growth loop requires a separate public surface.

Approach: deterministic HMAC-derived short hash per {userId,
issueDate} backed by a pointer key in Redis.

New files

- server/_shared/brief-share-url.ts
  Web Crypto HMAC helper. deriveShareHash returns 12 base64url chars
  (72 bits) from (userId, issueDate) using BRIEF_SHARE_SECRET.
  Pointer encode/decode helpers and a shape check. Distinct from the
  per-user BRIEF_URL_SIGNING_SECRET so a leak of one does not
  automatically unmask the other.

- api/brief/share-url.ts (edge, Clerk auth, Pro gated)
  POST /api/brief/share-url?date=YYYY-MM-DD
  Idempotently writes brief:public:{hash} pointer with the same 7 day
  TTL as the underlying brief, then returns {shareUrl, hash,
  issueDate}. 404 if the per-user brief is missing. 503 on Upstash
  failure. Accepts an optional refCode in the JSON body for referral
  attribution.

- api/brief/public/[hash].ts (edge, unauth)
  GET /api/brief/public/{hash}?ref={code}
  Reads pointer, reads the real brief envelope, renders with
  publicMode=true. Emits X-Robots-Tag: noindex,nofollow so shared
  briefs never get enumerated by search engines. 404 on any missing
  part (bad hash shape, missing pointer, missing envelope) with a
  neutral error page. 503 on Upstash failure.

Renderer changes (server/_shared/brief-render.js)

- Signature extended: renderBriefMagazine(envelope, options?)
  - options.publicMode: redacts user.name and whyMatters before any
    HTML emission; swaps the back cover to a Subscribe CTA; prepends
    a Subscribe strip across the top of the deck; omits the Share
    button + share script; adds a noindex meta tag.
  - options.refCode: appended as ?ref= to /pro links on public views.
- Non-public views gain a sticky .wm-share pill in the top-right
  chrome. Inline SHARE_SCRIPT handles the click flow: POST /api/
  brief/share-url then navigator.share with clipboard fallback and a
  prompt() ancient-browser fallback. User-visible feedback via
  data-state on the button (sharing / copied / error). No change to
  the envelope contract, no LLM calls, no composer-side work
  required.
- Validation runs on the full unredacted envelope first, so the
  public path can never accept a shape the private path would reject.

Tests

- tests/brief-share-url.test.mts (18 assertions): determinism,
  secret sensitivity, userId/date sensitivity, shape validation, URL
  composition with/without refCode, trailing-slash handling on
  baseUrl, pointer encode/decode round-trip.
- tests/brief-magazine-render.test.mjs (+13 assertions): Share
  button carries the issue date; share script emitted once;
  share-url endpoint wired; publicMode strips the button+script,
  replaces whyMatters, emits noindex meta, prepends Subscribe strip,
  passes refCode through with escaping, swaps the back cover, does
  not leak the user name, preserves story headlines, options-less
  call matches the empty-options call byte for byte.
- Full typecheck/lint/edge-bundle/test:data/edge-functions suite all
  green: 5704/5704 data tests, 171/171 edge-function tests, 0 lint
  errors.

Env vars (new)

- BRIEF_SHARE_SECRET: 64+ random hex chars, Vercel (edge) only. NOT
  needed by the Railway composer because pointer writes are lazy
  (on share, not on compose).

* fix(brief): public share round-trip + magazine Share button without auth

Two P1 findings on #3183 review.

1) Pointer wire format: share-url.ts wrote the pointer as a raw colon-delimited string via SET. The public route reads via readRawJsonFromUpstash which ALWAYS JSON.parses. A bare non-JSON string throws at parse, the route returned 503 instead of resolving. Fix: JSON.stringify on both write sites. Regression test locks the wire format.

2) Share button auth unreachable from a standalone magazine tab: inline script needed window.WM_CLERK_JWT which is never set, endpoint hard-requires Bearer, fallback to credentials:include fails. Fix: derive share URL server-side in the per-user route (same inputs share-url uses), embed as data-share-url, click handler now reads dataset and invokes navigator.share directly. No network, no auth, works in any tab.

The /api/brief/share-url endpoint stays in place for other callers (dashboard panel) with its Clerk auth intact and its pointer write now in the correct format.

QA: typecheck clean, 5708/5708 data tests, 45/45 magazine, 20/20 share-url, edge bundle OK, lint exit 0.

* fix(brief): address remaining review findings on #3183

P0-2 (comment-only): public/[hash].ts inline comment incorrectly described readRawJsonFromUpstash parse-failure behaviour. The helper rethrows on JSON.parse failure, it does not return null. Rewrote the comment to match reality (JSON-encoded wire format, parse-to-string round-trip, intentional 503-on-bug-value as the loud failure mode). The actual wire-format fix was in prior commit 045771d55.

P2 (consistency): publicStripHtml href was built via template literal + encodeURIComponent without the final escapeHtml wrap that renderBackCover uses. Safe in practice (encodeURIComponent handles all HTML-special chars + route boundary restricts refCode to [A-Za-z0-9_-]) but inconsistent. Unified by extracting publicStripHref and escaping on interpolation, matching the sibling function.

QA: typecheck clean, 45/45 magazine tests pass, lint exit 0.
2026-04-18 22:46:22 +04:00

43 lines
1.7 KiB
TypeScript

import type { BriefEnvelope } from '../../shared/brief-envelope.js';
/**
* Render options.
*
* - `publicMode`: when true, personal fields (user.name, per-story
* `whyMatters`) are replaced with generic placeholders, the back
* cover swaps to a Subscribe CTA, a top Subscribe strip is added,
* and the Share button + script are suppressed. Used by the
* unauth'd /api/brief/public/{hash} route.
*
* - `refCode`: optional referral code; interpolated into the public
* Subscribe CTAs as `?ref=<code>` for signup attribution. Shape-
* validated at the route boundary; still HTML-escaped here.
*
* - `shareUrl`: absolute URL that the Share button will invoke via
* `navigator.share` / clipboard fallback. Derived server-side by
* the per-user magazine route so the click handler makes no
* network calls and does not require Clerk session context. When
* omitted (or empty) the Share button is suppressed entirely
* (graceful degrade if BRIEF_SHARE_SECRET is unconfigured). Always
* ignored under publicMode.
*/
export interface RenderBriefMagazineOptions {
publicMode?: boolean;
refCode?: string;
shareUrl?: string;
}
export function renderBriefMagazine(
envelope: BriefEnvelope,
options?: RenderBriefMagazineOptions,
): string;
/**
* Validates the entire envelope (closed-key contract, field shapes,
* version, and the `surfaced === stories.length` cross-field rule).
* Shared between the renderer (call site: `renderBriefMagazine`) and
* preview readers that must honour the same contract so a "ready"
* preview never points at an envelope the renderer will reject.
*/
export function assertBriefEnvelope(envelope: unknown): asserts envelope is BriefEnvelope;