The whole purpose of memo is to put gates between strangers and the hosted HTML. The gate flow is a tiny state machine. Every visitor request to /<slug>/* runs the same machine and either rejects or advances the visitor to the next step.
┌─── claims = readClaims(cookie, slug) ───┐
│ │
▼ ▼
loadLink(slug) ──── not_found ───────────────► 404
│
├──── inactive ──────────────────────► 403 "disabled"
├──── expired ───────────────────────► 410 "expired"
▼
ok(link) ── nextStep(link, claims) ──────┐
▼
┌── email ────► /<slug>/email
├── otp ──────► /<slug>/otp
├── password ─► /<slug>/password
├── nda ──────► /<slug>/nda
└── view ─────► /<slug>/ (canonical trailing slash)
nextStep is pure:
function nextStep(link, claims) {
if (link.requireEmail && !claims?.email) return "email";
if (link.verifyEmail && !claims?.email_verified) return "otp";
if (link.passwordHash && !claims?.password_passed) return "password";
if (link.requireAgreementId && !claims?.nda_signed) return "nda";
return "view";
}
Every gate page calls loadLink + readClaims + nextStep. If the next step isn't the page you're on, you get redirected. This makes the gates idempotent — refreshing or deep-linking always lands you on the right gate.
The cookie name is memo_v_<slug> (lowercased, alphanumerics + -_ only). Attributes:
HttpOnly — never readable from JS.Secure — set automatically when the request scheme is https:.SameSite=Lax — keeps the cookie out of cross-site forms but allows top-level navigation.Max-Age=86400 — visitors must re-enter the gate flow daily.The cookie value is a jose HS256 JWT issued by src/lib/auth/visitor-jwt.ts. Payload:
type VisitorClaims = {
sub: string; // UUID, also reused as visits.id
slug: string; // bound to this slug
email?: string;
email_verified?: boolean;
password_passed?: boolean;
nda_signed?: boolean;
signature_id?: string;
};
The signing key is daily-rotated:
memo:jwt:key:current — written by the rotate-jwt-key cron.memo:jwt:key:previous — kept for 48 h grace, so visitors with a stale cookie never see a hard logout the moment the cron fires.MEMO_JWT_KEY_FALLBACK — env var, used until the first rotation succeeds.verifyVisitorJwt tries the current key, then the previous, and returns null if neither works.
/<slug>/email is a single <input type=email>. The POST handler at /api/v/<slug>/email:
emailAllowedByLink(link, email) — block-list wins; if no allowlist is set, anything not blocked passes.link.verifyEmail, issues a 6-digit OTP via issueOtp (Redis SET ... EX 300 NX) and sends through Resend.email set (and email_verified: !link.verifyEmail).nextStep(link, claims)./<slug>/otp shows the masked email. POST handler:
claims.email is missing the visitor is bounced back to /<slug>/email.verifyOtp — atomically deletes on match so codes aren't reusable.email_verified: true.POST handler at /api/v/<slug>/password:
verifyPassword(plain, { hash, salt }) — argon2id with the env pepper.password_passed: true./<slug>/nda renders the agreement body (set:html from Tiptap output) with a "type your full name" input. POST handler at /api/v/<slug>/sign:
claims.email exists (NDAs without identifying the signer are worthless).signatures row with the typed-name SVG, agreement_version_hash (frozen at sign time so re-edits of the agreement don't invalidate prior signatures), IP, UA.action=nda.sign./api/cron/render-signed-pdf with the signature_id. The PDF is rendered asynchronously by lib/pdf/generate.ts (lazy @sparticuz/chromium-min + playwright-core) and back-fills signed_pdf_r2_key.nda_signed: true, signature_id./<slug>/ is the destination — the canonical trailing-slash URL identical to what the owner shared. (/<slug>/view is a permanent 308 redirect to here, kept for any pre-existing bookmarks.) Why the trailing slash matters: served HTML often has refs like <img src="hero.png">, which the browser resolves against the document's directory. /<slug> (no slash) makes the browser treat <slug> as a file and resolves to /hero.png (root 404). /<slug>/ resolves to /<slug>/hero.png and hits the asset catch-all correctly. The index.astro handler 308-redirects /<slug> → /<slug>/ once gates pass.
The handler then:
nextStep check.visits row keyed by claims.sub.index.html in the version, else the first text/html file.text/html; charset=utf-8 with Cache-Control: private, no-store.Per-asset requests (images, JS, fonts) go through /<slug>/raw/<path> which re-runs the gate check before streaming the asset. There is no public CDN cache — visitor content is private.
| Endpoint | Identifier | Window |
|---|---|---|
/api/v/<slug>/email | slug + IP | 10/min |
/api/v/<slug>/otp | slug + IP | 10/min |
/api/v/<slug>/password | slug + IP | 10/min |
/api/v/<slug>/sign | slug + IP | 5/min |
/api/beacon | slug + IP | 60/min |
/api/recording/logrocket | visit + IP | 6/min |
All limiters are sliding-window via @upstash/ratelimit.
The link gate and the space gate are the same state machine over two entities. The per-step POST handlers are produced by factories in src/lib/visitor/gate-handler-factories.ts:
createEmailHandler(opts) · createOtpHandler(opts) · createPasswordHandler(opts) · createSignHandler(opts)
Each factory takes a GateHandlerOptions describing how to load the entity, read/sign the JWT, compute the next step, and build the cookie — so /api/v/<slug>/email and /api/s/<spaceSlug>/email share one implementation. Links pass the link loaders; spaces pass loadSpace / readSpaceClaims / signSpaceVisitorJwt / the memo_vs_ cookie builder. The generic cookie/step helpers (nextStepFor, cookieNameFor, readClaimsFromCookie, cookieHeaderFor, …) live in gate.ts and are wrapped by both gate.ts (links) and space-gate.ts (spaces).
A non-ok load maps to the right status via a shared helper: inactive → 403, expired → 410, else 404 (the internal status string is no longer leaked as the body). The email factory also runs an optional checkTeamAccess hook — see Teams.
A space gate is the link gate over a spaces row. loadSpace / spaceNextStep / readSpaceClaims mirror the link helpers; the POST routes at /api/s/<spaceSlug>/{email,otp,password,sign} are the shared factories above. Once spaceNextStep === "view", content is served by the native viewer (/s/<slug>/p/<projectId>/), never by redirecting to a link. Full detail: Spaces → The gate.
The sign handler writes a signatures row. Because signatures.link_id FKs to links, a space signature must write space_id instead — the factory's signatureTarget: "linkId" | "spaceId" option routes the entity id to the correct column (links default to linkId; the space sign route sets signatureTarget: "spaceId"). Writing a space id into link_id would raise a FK violation, so this routing is load-bearing.
readClaims(cookie, slug) (links) and readSpaceClaims(cookie, slug) (spaces) reject a token whose slug claim doesn't match the requested slug — a JWT minted for A is never honored for B even if its cookie reaches B's handler. Cookie naming (memo_v_<slug> / memo_vs_<spaceSlug>) already isolates by slug; the claim check is defense-in-depth.
After view, injectVisitorChrome(html, opts) (src/lib/visitor/watermark.ts) appends, before </body>: the watermark SVG overlay (when watermark), screenshot-protection CSS, memo-visit / memo-slug / optional memo-scope metas, the beacon (beacon !== false), the Q&A widget (qaWidget !== false), download/contextmenu disable (unless allowDownloads), and branding CSS vars. The space viewer passes scope: "space", qaWidget: false, and beacon only when there's a session; extraTail carries the recording snippet.
audit_logs indirectly via the JWT mutation; every NDA sign writes an explicit row.