Symptom-driven. Each section is "you see X → check Y → fix Z."
Symptoms: OTP emails not arriving. Visitors stuck on /<slug>/otp.
Check:
GET https://api.resend.com/emails/<recent_id> — did Resend accept the send?audit_logs for the visit — nda.sign won't be there, but the gate progression doesn't write per-step audit rows; check Sentry for handler errors.GET memo:otp:<slug>:<lowercased-email> — is the code present?Fix:
memo.matters.ai in Resend's dashboard.memo:rl:visitor-email:*)./<slug>/password if the link also has a password.Symptoms: Activity table empty or missing recent visits.
Check:
SELECT count(*) FROM memo.visits WHERE link_id IN (SELECT id FROM memo.links WHERE project_id = '<projectId>') — is the data in Postgres?view.astro is failing. Check Sentry for view.astro errors.Fix:
ON CONFLICT DO NOTHING keyed on id — so if the same JWT sub is reused, no row is inserted. This is correct behavior.NEON_DATABASE_URL is set and Neon's compute is awake.Check:
LOGROCKET_APP_ID in Vercel env?https://cdn.lr-ingest.io in script-src? (Yes, default policy includes it.)getSessionURL and POST to /api/recording/logrocket?Fix:
visits.logrocket_session_url is null, the round-trip POST failed. Check Sentry on /api/recording/logrocket; common cause is a CSP block on cdn.lr-ingest.io (default CSP allows it; an ALLOWED_HOSTS override that omits LogRocket would block it).{ visit, slug, url } in the JSON body and re-verifies the visitor JWT against the slug; if either is wrong, the update is dropped silently.Symptoms: signatures.signed_pdf_r2_key is null long after a successful sign.
Check:
QSTASH_TOKEN set?MEMO_CHROMIUM_PACK_URL correct? Without it, @sparticuz/chromium-min falls back to a local binary which won't exist in Vercel's serverless environment.Fix:
curl -X POST -H "Upstash-Signature: …" https://memo.matters.ai/api/cron/render-signed-pdf -d '{"signatureId":"…"}'.Check:
CRON_SECRET set in Vercel env?Authorization header doesn't match. Vercel sets the header automatically; if it's mis-cased or the value is wrong, every cron fires but does nothing.Fix:
CRON_SECRET if you suspect it was rotated incorrectly.curl -H "Authorization: Bearer $CRON_SECRET" https://memo.matters.ai/api/cron/expire-links.Symptoms: memo.visits rows are landing fine; db.events.find({ link_id: ... }) in Mongo returns nothing for recent visits; Activity tab dwell-time/scroll-depth numbers are blank.
Background: public/beacon.js posts events to /api/beacon with the slug embedded in the body. The slug used to be parsed from location.pathname.split("/")[2] (the position the slug occupied under the old /v/<slug>/view URL scheme). When visitor pages moved to the root and later /view was collapsed into /<slug>/ (canonical trailing slash), URL-position parsing breaks silently — /api/beacon 204s because loadLink("view") or loadLink("") finds nothing.
Check:
/<slug>/ page. Both meta tags must be present:
<meta name="memo-visit" content="<visit-uuid>">
<meta name="memo-slug" content="<slug>">
memo-slug is missing, injectVisitorChrome in src/lib/visitor/watermark.ts regressed./api/beacon with a manual sendBeacon and confirm it does NOT 204:
navigator.sendBeacon('/api/beacon', JSON.stringify({
slug: 'YOUR-SLUG', visit: 'YOUR-VISIT-UUID',
events: [{ type: 'page_view', payload: {}, t: Date.now() }],
}));
public/beacon.js reads slug from the meta tag, not from location.pathname:
var slugMeta = document.querySelector('meta[name="memo-slug"]');
var slug = slugMeta ? slugMeta.getAttribute("content") : "";
Fix: restore the meta-tag handoff in both src/lib/visitor/watermark.ts (server injection) and public/beacon.js (client read). The URL-position parser is a footgun — never bring it back. Same lesson applies to /api/recording/logrocket: it takes slug in the body rather than parsing Referer.
Symptoms: Performance tab → "Section engagement" shows the placeholder text "No section data yet. Tag sections in your HTML with <section data-memo-section="hero">" even after visitors have viewed the deck.
Check:
/<slug>/ page (signed in as the visitor or yourself) and grep for data-memo-section. If zero matches, the deck's HTML lacks the attribute and the IntersectionObserver has nothing to instrument.beacon. You should see POST /api/beacon requests every ~5s as the visitor scrolls. If no requests, the beacon isn't loaded — see the "Activity tab shows visits but no scroll / click / dwell data" entry above.section_exit events landing in Mongo?
db.events.find({ project_id: "<projectId>", type: "section_exit" }).limit(5)
position: fixed ancestor — IntersectionObserver against the viewport gets confused.display: none or visibility: hidden at observe time.Fix:
<section data-memo-section="…"> with stable kebab-case ids: hero, problem, financials, team, cta. Avoid slide-1 / slide-2 (rewrites change indices and break historical aggregation).Cache-Control: private, max-age=30 — refresh after 30s or hard-reload to bypass.Symptoms: Activity tab shows visits with engagement scores but the Signals column is empty (no skim / deep_read / return / share_detected chips).
Background: Two paths produce signals:
skim / deep_read) — emitted inline by /api/beacon when the visit's page_leave event arrives. If the visit never page_leaves (visitor closed the tab via task manager, network died mid-flight), no per-visit signal fires.return / share_detected) — emitted by the derive-cross-visit-signals cron every 10 minutes.Check for per-visit signals:
SELECT id, ended_at, duration_ms, completion_pct FROM memo.visits WHERE id = '<uuid>'. If ended_at IS NULL, no page_leave arrived — no skim / deep_read will fire. Expected for tabs that visitors actually closed cleanly; problem for tabs that crashed.src/lib/analytics/derive-signals.ts:
skim: completion ≥ 80 AND duration < 30s AND sections ≤ 2. ALL three required.deep_read: sections ≥ 5 AND duration ≥ 3min AND avg section dwell ≥ 45s.SELECT * FROM memo.visit_signals WHERE visit_id = '<uuid>';
Check for cross-visit signals:
SELECT * FROM memo.audit_logs
WHERE action = 'visit_signals.derive'
ORDER BY ts DESC LIMIT 5;
meta containing returnCount and shareCount. If 0/0 consistently, no thresholds are being met.visitor_persistent_id values populated? SELECT COUNT(*) FROM memo.visits WHERE visitor_persistent_id IS NULL — if non-zero for recent visits, the memo_vp cookie path isn't running (e.g., migration 0004 wasn't applied).Fix:
\d memo.visits | grep visitor_persistent_id.\d memo.visit_signals should show the table.vercel.json and the latest deploy includes the new cron path.curl -X GET -H "Authorization: Bearer $CRON_SECRET" https://memo.matters.ai/api/cron/derive-cross-visit-signals.Symptoms: Activity tab open, a new visitor arrives, but no row slides in at the top with the yellow-fade animation. Page refresh shows the new row.
Background: The Activity tab polls /api/owner/projects/<id>/activity/since?ts=<iso> every 8 seconds and prepends new rows. Polling only runs in "Recent" sort mode (when ?sort=score is on the URL, the page would have to re-sort on every poll which would be visually thrashy).
Check:
…/activity (Recent) or …/activity?sort=score (Engagement)? In Engagement mode, polling is intentionally disabled.since. You should see one request every 8s. If you don't, the inline <script> in activity.astro didn't run — check the page console for errors.serverTime from the response as the next ?ts=. If the server clock is way off (it shouldn't be on Vercel), the gt(started_at, ts) query returns empty even for fresh visits.Fix:
curl -b "<your-cookie-jar>" "https://memo.matters.ai/api/owner/projects/<id>/activity/since?ts=$(date -u -v-1H +%Y-%m-%dT%H:%M:%SZ)"
fetch, etc.). If it returns empty, the visits genuinely aren't landing — see "Activity tab shows visits but no scroll / click / dwell data".Symptoms: Edited and saved a file in the editor; the Changes panel (activity bar's third icon) shows "No changes vs v3" instead of listing the file.
Background: The Changes panel queries /api/owner/projects/<id>/changes, which compares staged files (everything in R2 under staging/<projectId>/) against the current published version's memo.files rows. The comparison is by sha256 of file bytes.
Check:
PUT /api/owner/projects/<id>/files/<path> after your save. Status should be 204 or 200.staging/<projectId>/ — if the save somehow wrote elsewhere, the diff sees nothing.changesRefreshKey bumps. The shell bumps it on save and on publish. If you're looking at a stale render, force a panel re-mount (collapse activity bar then open Changes again).Background: scraperGate blocks any UA matching the blocklist regex on visitor pages (/<slug>/* — anything that isn't an app path like /u/, /api/, /oauth/, /.well-known/, etc.).
Fix:
src/middleware.ts./health (the visitor surface is intentionally hostile).Check:
expire-links running every 15 min? Vercel dashboard.audit_logs for action='link.expire' — recent entries?links where expires_at < now() AND active = true — should be empty after the cron runs.Fix:
expires_at is stored as timestamptz (the cron compares against now()).UPDATE memo.links SET active = false WHERE expires_at < now() - interval '1 minute';Symptoms: Some visitors get logged out unexpectedly while others are fine.
Background: The rotate cron runs once per day at 03:17 UTC; Upstash propagates within seconds globally. The previous key with 48 h TTL covers any rare propagation delay.
Fix:
MEMO_JWT_KEY_FALLBACK is your last line of defense, visitors with cookies signed by the old :current key will silently fail. Re-running the cron manually re-establishes the chain.upstash-cli redis del memo:jwt:key:current memo:jwt:key:previous
curl -H "Authorization: Bearer $CRON_SECRET" https://memo.matters.ai/api/cron/rotate-jwt-key
This wipes both keys and rotates a fresh one. Every visitor cookie is now invalid; they re-enter the gate flow on their next request.
R2 retains files keyed by <userId>/<projectId>/<versionId>/. Postgres-side cascading deletes drop the versions + files rows, but R2 is not auto-pruned.
Fix:
Manual cleanup script (not in repo yet):
aws s3 rm s3://memo/<userId>/<projectId>/ --recursive \
--endpoint-url https://<account>.r2.cloudflarestorage.com
Roadmap: a cleanup-orphans QStash job that finds R2 prefixes without matching versions rows and deletes them after a 30-day grace.
SELECT v.started_at, v.visitor_email, v.ip, v.duration_ms, v.logrocket_session_url
FROM memo.visits v
JOIN memo.links l ON l.id = v.link_id
WHERE l.slug = '<slug>'
ORDER BY v.started_at DESC;
For per-event detail:
db.events.find({ link_id: "<uuid>" }).sort({ ts: 1 })
For signed NDAs:
SELECT s.signed_at, s.visitor_email, s.ip, s.agreement_version_hash, s.signed_pdf_r2_key
FROM memo.signatures s
JOIN memo.links l ON l.id = s.link_id
WHERE l.slug = '<slug>';
See Operations § Local development. Cron endpoints can be triggered manually with the CRON_SECRET Bearer header.
To trigger the visitor flow end-to-end locally, set ALLOWED_HOSTS=memo.matters.ai,localhost,127.0.0.1 (loopback is already accepted automatically) and use a real Resend send to a real inbox, or temporarily stub sendOtpEmail to log the code to stdout.
schema.ts is the single source of truth. The drizzle/ chain is a single clean baseline (0000_init.sql + meta/0000_snapshot.json) that exactly reflects it — drizzle-kit generate reports "No schema changes." The pre-Phase-1 → current incremental ALTERs are archived in apps/memo/drizzle-legacy/ (with a README) for upgrading an existing database by hand.
Apply to a live DB — recommended (push): from apps/memo/
pnpm exec dotenv -e <path>/.env.memo.prod -- pnpm exec drizzle-kit push
push diffs schema.ts against the live DB and applies exactly the missing changes — it ignores the migration files, so it works regardless of what the DB has applied. ⚠️ It writes immediately — review the printed statements before confirming. A second run should report "No changes."
Fresh database: drizzle/0000_init.sql (or drizzle-kit migrate/push) creates the entire current schema. Never apply 0000_init.sql to an existing DB — it's a full CREATE-everything baseline and will conflict. Use push (or the drizzle-legacy/ ALTERs in order) instead.
Required env beyond the base set:
| Var | For |
|---|---|
PROVIDER_TOKEN_ENCRYPTION_KEY | Cloud-storage token encryption (optional — falls back to WEBHOOK_ENCRYPTION_KEY). 64 hex chars (openssl rand -hex 32). |
GOOGLE_CLIENT_ID/SECRET, DROPBOX_CLIENT_ID/SECRET, BOX_CLIENT_ID/SECRET | Cloud-storage connect (each provider independent) |