File size: 9,765 Bytes
3464008 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 | /**
* POST /api/brief/share-url?slot=YYYY-MM-DD-HHMM
* -> 200 { shareUrl, hash, issueSlot } on success
* -> 401 UNAUTHENTICATED on missing/bad JWT
* -> 403 pro_required for non-PRO users
* -> 400 invalid_slot_shape / invalid_payload on bad inputs
* -> 404 brief_not_found when the per-user
* brief key is missing (reader can't share what doesn't exist)
* -> 503 service_unavailable on env/Upstash failure
*
* Omitting ?slot= defaults to the user's most recent brief via the
* brief:latest:{userId} pointer the digest cron writes. That covers
* the Share button in the hosted magazine β it already carries the
* slot in its path β but also gives dashboard/test callers a path
* that doesn't need to know the slot.
*
* Materialises the brief:public:{hash} pointer used by the unauth'd
* /api/brief/public/{hash} route. Idempotent β the hash is a pure
* function of {userId, issueSlot, BRIEF_SHARE_SECRET}, so repeated
* calls for the same reader+slot always return the same URL and
* overwrite the pointer with the same value (refreshing its TTL).
*
* Writing the pointer LAZILY (on share, not on compose) keeps the
* composer side-effect-free and means public URLs only exist for
* briefs a user has actively chosen to share. A pointer that never
* gets written simply means nobody shared that brief.
*/
export const config = { runtime: 'edge' };
// @ts-expect-error β JS module, no declaration file
import { getCorsHeaders, isDisallowedOrigin } from '../_cors.js';
// @ts-expect-error β JS module, no declaration file
import { jsonResponse } from '../_json-response.js';
import { readRawJsonFromUpstash, redisPipeline } from '../_upstash-json.js';
// @ts-expect-error β JS module, no declaration file
import { captureSilentError } from '../_sentry-edge.js';
import { validateBearerToken } from '../../server/auth-session';
import { checkProEntitlement } from '../../server/_shared/pro-entitlement';
import {
BriefShareUrlError,
BRIEF_PUBLIC_POINTER_PREFIX,
buildPublicBriefUrl,
encodePublicPointer,
} from '../../server/_shared/brief-share-url';
const ISSUE_SLOT_RE = /^\d{4}-\d{2}-\d{2}-\d{4}$/;
// Public pointer lives as long as the brief key itself (7 days), so
// the share link works for the entire TTL window even if the user
// clicks Share on day 6. Using the same constant as the composer
// (see scripts/seed-digest-notifications.mjs BRIEF_TTL_SECONDS)
// keeps the two sides in lockstep.
const BRIEF_TTL_SECONDS = 7 * 24 * 60 * 60;
/**
* Public base URL for the share links we mint. Pinned to
* WORLDMONITOR_PUBLIC_BASE_URL in prod to prevent host-header
* reflection from producing share URLs pointing at preview deploys
* or other non-canonical origins.
*/
function publicBaseUrl(req: Request): string {
const pinned = process.env.WORLDMONITOR_PUBLIC_BASE_URL;
if (pinned) return pinned.replace(/\/+$/, '');
return new URL(req.url).origin;
}
export default async function handler(
req: Request,
ctx?: { waitUntil: (p: Promise<unknown>) => void },
): Promise<Response> {
if (isDisallowedOrigin(req)) {
return jsonResponse({ error: 'Origin not allowed' }, 403);
}
const cors = getCorsHeaders(req, 'POST, OPTIONS');
if (req.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: cors });
}
if (req.method !== 'POST') {
return jsonResponse({ error: 'Method not allowed' }, 405, cors);
}
const authHeader = req.headers.get('Authorization') ?? '';
const jwt = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
if (!jwt) return jsonResponse({ error: 'UNAUTHENTICATED' }, 401, cors);
const session = await validateBearerToken(jwt);
if (!session.valid || !session.userId) {
return jsonResponse({ error: 'UNAUTHENTICATED' }, 401, cors);
}
const proAccess = await checkProEntitlement(session.userId, session.role, cors);
if (!proAccess.allowed) {
// #5600: an entitlement the backend could not VERIFY is not a confirmed
// free user. Answer the shared retryable contract (503 + Retry-After) for
// those states before falling back to the terminal upsell. Note this covers
// lookup failure and renewal verification only β the day-0 poisoned-marker
// cohort arrives as a plain tier-0 answer and still gets the 403; that
// window is bounded by NOT_APPLICABLE_VERIFICATION_TTL_SECONDS instead.
const { billingDenial } = proAccess;
if (billingDenial) return billingDenial;
return jsonResponse(
{ error: 'pro_required', message: 'Sharing is available on the Pro plan.' },
403,
cors,
);
}
const secret = process.env.BRIEF_SHARE_SECRET ?? '';
if (!secret) {
console.error('[api/brief/share-url] BRIEF_SHARE_SECRET is not configured');
return jsonResponse({ error: 'service_unavailable' }, 503, cors);
}
// Slot may come from ?slot=YYYY-MM-DD-HHMM OR a JSON body, OR be
// omitted β in which case we look up the user's most recent brief
// via the latest-pointer the cron writes. That lets dashboard/test
// callers POST without knowing the slot while the magazine Share
// button can still pass its own slot through explicitly.
const url = new URL(req.url);
let issueSlot = url.searchParams.get('slot');
let refCode: string | undefined;
if (!issueSlot || req.headers.get('content-type')?.includes('application/json')) {
try {
const body = (await req.json().catch(() => null)) as
| { slot?: unknown; refCode?: unknown }
| null;
if (!issueSlot && typeof body?.slot === 'string') issueSlot = body.slot;
if (typeof body?.refCode === 'string' && body.refCode.length > 0 && body.refCode.length <= 32) {
refCode = body.refCode;
}
} catch {
/* ignore β empty body is fine when ?slot= carries the value */
}
}
// Remember whether the caller supplied anything at all, so we can
// distinguish two miss modes below: bad input shape vs. "no brief
// exists yet for this user". Empty/whitespace counts as omitted.
const callerProvidedSlot =
typeof issueSlot === 'string' && issueSlot.trim().length > 0;
if (!callerProvidedSlot) {
// No slot given β fall back to the latest-pointer the cron writes.
try {
const latest = await readRawJsonFromUpstash(`brief:latest:${session.userId}`);
const slot = (latest as { issueSlot?: unknown } | null)?.issueSlot;
if (typeof slot === 'string' && ISSUE_SLOT_RE.test(slot)) {
issueSlot = slot;
} else {
// Pointer missing (never composed / TTL expired) β this is a
// "no brief to share" condition, not an input-shape problem.
// Return the same 404 the existing-brief check would return
// so the caller gets a coherent contract: either the brief
// exists and is shareable, or it doesn't and you get 404.
return jsonResponse({ error: 'brief_not_found' }, 404, cors);
}
} catch (err) {
console.error('[api/brief/share-url] latest pointer read failed:', (err as Error).message);
captureSilentError(err, { tags: { route: 'api/brief/share-url', step: 'latest-pointer-read' }, ctx });
return jsonResponse({ error: 'service_unavailable' }, 503, cors);
}
}
if (!issueSlot || !ISSUE_SLOT_RE.test(issueSlot)) {
return jsonResponse({ error: 'invalid_slot_shape' }, 400, cors);
}
// Ensure the per-user brief actually exists before minting a share
// URL β otherwise the public route would 404 on the recipient's
// click and the sender wouldn't know why. A read-before-write also
// gives a clean 503 path if Upstash is down.
let existing: unknown;
try {
existing = await readRawJsonFromUpstash(`brief:${session.userId}:${issueSlot}`);
} catch (err) {
console.error('[api/brief/share-url] Upstash read failed:', (err as Error).message);
captureSilentError(err, { tags: { route: 'api/brief/share-url', step: 'envelope-read' }, ctx });
return jsonResponse({ error: 'service_unavailable' }, 503, cors);
}
if (existing == null) {
return jsonResponse({ error: 'brief_not_found' }, 404, cors);
}
let shareUrl: string;
let hash: string;
try {
const built = await buildPublicBriefUrl({
userId: session.userId,
issueDate: issueSlot,
baseUrl: publicBaseUrl(req),
secret,
refCode,
});
shareUrl = built.url;
hash = built.hash;
} catch (err) {
if (err instanceof BriefShareUrlError) {
console.error(`[api/brief/share-url] ${err.code}: ${err.message}`);
return jsonResponse({ error: 'service_unavailable' }, 503, cors);
}
throw err;
}
// Idempotent pointer write. Same {userId, issueSlot, secret} always
// produces the same hash, so this SET overwrites with an identical
// value on repeat shares and resets the TTL window.
//
// CRITICAL: store as JSON-encoded so readRawJsonFromUpstash() on the
// public route round-trips successfully. That helper always
// JSON.parse's the Redis value; a bare colon-delimited string would
// throw at parse time and the public route would 503 instead of
// resolving the pointer.
const pointerKey = `${BRIEF_PUBLIC_POINTER_PREFIX}${hash}`;
const pointerValue = JSON.stringify(encodePublicPointer(session.userId, issueSlot));
const writeResult = await redisPipeline([
['SET', pointerKey, pointerValue, 'EX', String(BRIEF_TTL_SECONDS)],
]);
if (writeResult == null) {
console.error('[api/brief/share-url] pointer write failed');
return jsonResponse({ error: 'service_unavailable' }, 503, cors);
}
return jsonResponse({ shareUrl, hash, issueSlot }, 200, cors);
}
|