This page is the single reference for memo's environment surface and how to keep the system running. Pair it with the in-repo apps/memo/DEPLOYMENT.md which has the click-by-click bootstrap.
Memo expects ~42 environment variables in production (the full set is in apps/memo/.env.example). The core ones, categorized by what they unlock, are below; the realtime / collaboration and integrations groups are documented on Collaboration and Integrations.
| Var | Notes |
|---|---|
PUBLIC_CLERK_PUBLISHABLE_KEY | Memo-specific Clerk app — do not reuse the workspace.matters.ai keys |
CLERK_SECRET_KEY |
| Var | Notes |
|---|---|
NEON_DATABASE_URL | Postgres connection string; must include ?sslmode=require |
MONGODB_MEMO_URI | Atlas connection string |
MONGODB_MEMO_DB | memo |
| Var | Notes |
|---|---|
R2_ACCOUNT_ID | Cloudflare account |
R2_ACCESS_KEY_ID | R2 API token (read+write on the bucket) |
R2_SECRET_ACCESS_KEY | |
R2_BUCKET | Default memo |
| Var | Notes |
|---|---|
UPSTASH_REDIS_REST_URL | |
UPSTASH_REDIS_REST_TOKEN | |
QSTASH_TOKEN | Required for signed-NDA PDF rendering |
QSTASH_CURRENT_SIGNING_KEY | |
QSTASH_NEXT_SIGNING_KEY |
| Var | Notes |
|---|---|
LOGROCKET_APP_ID | |
CLARITY_PROJECT_ID |
| Var | Notes |
|---|---|
RESEND_API_KEY | |
RESEND_FROM_EMAIL | "Memo <noreply@memo.matters.ai>" |
| Var | Notes |
|---|---|
MEMO_PASSWORD_PEPPER | Hex string, never rotate |
MEMO_JWT_KEY_FALLBACK | Base64; replaced by daily-rotated key in Redis after first cron |
CRON_SECRET | Vercel native cron auth |
| Var | Default | Notes |
|---|---|---|
MEMO_REC_RRWEB | true | Self-hosted rrweb capture + replay. No creds required. |
MEMO_REC_LOGROCKET | true if LOGROCKET_APP_ID | Inject LogRocket snippet. |
MEMO_REC_CLARITY | true if CLARITY_PROJECT_ID | Inject Clarity snippet. |
All three are independently toggleable. Set any to false to force off even when credentials are present. See /memo/replay for the full design.
Add to vercel.json crons (next to the existing two):
{ "path": "/api/cron/dispatch-webhooks", "schedule": "*/2 * * * *" }
Fires every 2 minutes. Picks up webhook_deliveries rows where delivered_at IS NULL AND next_attempt_at <= now(). Same dual-auth (Vercel CRON_SECRET or Upstash signature) as the other crons.
| Var | Notes |
|---|---|
ANTHROPIC_API_KEY | Required for the editor's Generate-with-Claude button + /api/owner/ai/generate. Without it the route returns 503 anthropic_api_key_unset and the panel surfaces a toast. Note: OAuth + MCP do NOT need this; they're independent. |
The OAuth server and /api/mcp need no additional env beyond what's already listed. The same Neon + Upstash + Clerk infra is reused. The PAT + OAuth tables were added in migrations drizzle/0001_*.sql and drizzle/0002_*.sql respectively — apply them on first deploy.
| Var | Notes |
|---|---|
SENTRY_DSN_MEMO | Sentry project DSN |
SENTRY_AUTH_TOKEN | (Optional) for source-map upload — disabled in astro.config.mjs by default |
| Var | Notes |
|---|---|
PUBLIC_APP_URL | https://memo.matters.ai |
ALLOWED_HOSTS | memo.matters.ai (comma-separated for multi-host) |
MEMO_CHROMIUM_PACK_URL | (Optional) URL to chromium-min binary pack for the PDF renderer |
| Var | Notes |
|---|---|
MEMO_REALTIME_PROVIDER | Co-editing backend selector (liveblocks or the lean Cloudflare Durable-Object provider) |
MEMO_REALTIME_SECRET | Shared secret for the realtime token endpoint |
MEMO_REALTIME_DURABLE_HOST | Host of the Durable-Object realtime backend (when the DO provider is selected) |
LIVEBLOCKS_SECRET_KEY | Liveblocks server key (when the Liveblocks provider is selected) |
See Collaboration for the full realtime design.
| Var | Notes |
|---|---|
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET | Google Drive import OAuth app |
DROPBOX_CLIENT_ID / DROPBOX_CLIENT_SECRET | Dropbox import OAuth app |
BOX_CLIENT_ID / BOX_CLIENT_SECRET | Box import OAuth app |
Cloud-provider OAuth tokens are encrypted at rest. The encryption key is resolved from PROVIDER_TOKEN_ENCRYPTION_KEY, falling back to WEBHOOK_ENCRYPTION_KEY (src/lib/oauth/exchange.ts) — neither is in .env.example; provision one to enable token persistence. See Integrations.
| Var | Notes |
|---|---|
SES_INBOUND_TOPIC_ARN | SNS topic ARN for AWS SES inbound email → upload |
UPLOAD_EMAIL_DOMAIN | Domain that accepts upload-by-email |
The full template lives in apps/memo/.env.example. It is also surfaced in turbo cache invalidation via turbo.json wildcards (NEON_*, R2_*, UPSTASH_*, QSTASH_*, LOGROCKET_*, CLARITY_*, MEMO_*, ALLOWED_HOSTS, etc).
The web client uploads files directly to R2 using presigned PUT URLs (the
flow is presign → PUT → commit, with a unpack-zip chaser for ZIPs). This
sidesteps Vercel's 6 MB request body cap, gives per-file progress events, and
keeps server endpoints lightweight.
For the browser to be allowed to PUT cross-origin into R2, the
memo bucket needs the CORS policy below. Apply it once per bucket
— without it, every upload row fails with a Network error.
[
{
"AllowedOrigins": [
"https://memo.matters.ai",
"https://memo.matters.mreshank.com",
"http://localhost:4004"
],
"AllowedMethods": ["PUT", "GET", "HEAD"],
"AllowedHeaders": [
"Content-Type",
"Content-Length",
"Content-MD5",
"x-amz-content-sha256",
"x-amz-date",
"x-amz-meta-*",
"Authorization"
],
"ExposeHeaders": ["ETag", "x-amz-version-id"],
"MaxAgeSeconds": 3600
}
]
Apply via the Cloudflare dashboard (R2 → memo → Settings → CORS
Policy → paste) or via wrangler:
wrangler r2 bucket cors put memo --rules @apps/memo/scripts/r2-cors.json
Verify with:
curl -X OPTIONS \
-H 'Origin: https://memo.matters.mreshank.com' \
-H 'Access-Control-Request-Method: PUT' \
https://<ACCOUNT_ID>.r2.cloudflarestorage.com/memo/staging/_probe -i
Expected 200 with Access-Control-Allow-Origin: https://memo.matters.mreshank.com.
The JSON is also committed at apps/memo/scripts/r2-cors.json
so it can be applied non-interactively in setup scripts. The legacy multipart
endpoint (POST /api/owner/projects/[id]/upload) is retained as a sub-4 MB
fallback for CLI/MCP callers; CORS does not apply to it because the
request is server-to-server.
Upload caps (server-side, enforced in the presign + unpack endpoints):
| Constant | Value | Where |
|---|---|---|
MAX_PRESIGN_FILE_BYTES | 100 MB | per file |
MAX_PRESIGN_TOTAL_BYTES | 500 MB | per presign batch |
MAX_PRESIGN_FILES_PER_CALL | 200 | per presign batch |
MAX_ZIP_BYTES | 200 MB | per ZIP on the wire |
MAX_ZIP_ENTRIES | 1000 | per ZIP after unzip |
Native Vercel crons are declared in apps/memo/vercel.json:
{
"crons": [
{ "path": "/api/cron/rotate-jwt-key", "schedule": "17 3 * * *" },
{ "path": "/api/cron/expire-links", "schedule": "*/15 * * * *" },
{ "path": "/api/cron/dispatch-webhooks", "schedule": "*/2 * * * *" },
{ "path": "/api/cron/derive-cross-visit-signals", "schedule": "*/10 * * * *" }
]
}
Each cron handler accepts both:
Authorization: Bearer ${CRON_SECRET} GET, orUpstash-Signature-signed QStash POST.This dual-auth lets us run the same endpoint from either system, which is useful for render-signed-pdf — that one is fired event-driven by /api/v/<slug>/sign via QStash, no Vercel cron entry.
rotate-jwt-key (daily, 03:17 UTC)#Reads memo:jwt:key:current, demotes it to memo:jwt:key:previous with a 48-hour TTL, generates a new 48-byte base64 secret, and writes it to current. Visitor JWTs signed with the previous key continue to validate for 48 h after rotation.
expire-links (every 15 min)#SELECT id FROM memo.links WHERE active = true AND expires_at < now() → for each, UPDATE active = false and write an audit_logs row with action='link.expire'. No-actor entries because the cron is the actor.
render-signed-pdf (event-triggered)#Called from POST /api/v/<slug>/sign via QStash with { signatureId }. Reads the signature + agreement, calls renderSignedAgreementPdf (lazy @sparticuz/chromium-min + playwright-core), uploads to signatures/<signatureId>.pdf in R2, and back-fills signatures.signed_pdf_r2_key.
dispatch-webhooks (every 2 min)#Picks up webhook_deliveries rows where delivered_at IS NULL AND next_attempt_at <= now(). For each, signs the body with HMAC-SHA256 (memo-signature: t=<unix>,v1=<hex>) and POSTs to the subscriber. On non-2xx response, increments attempts and sets next_attempt_at = now + 30s × 2^(attempts-1) (exponential backoff capped at 6 attempts).
derive-cross-visit-signals (every 10 min)#Sweeps memo.visits rows in the trailing 72 hours, groups by visitor_persistent_id (for return signal) and by email domain (for share_detected signal), runs the pure classifiers from src/lib/analytics/derive-signals.ts, and upserts into memo.visit_signals with ON CONFLICT (visit_id, kind) DO UPDATE.
return fires when ≥3 visits share the same visitor_persistent_id. Emitted on the most-recent of the qualifying visits so it surfaces "now" in the Activity feed.share_detected fires when ≥2 distinct emails from the same domain (lowercased) visit within the 72h window. Emitted on the most-recent of the colluding visits.The cron writes one audit_logs row per run with action='visit_signals.derive' and meta={ windowHours, returnCount, shareCount } so you can confirm it's executing in production. See Observability → Cross-visit cron for the full algorithm.
Drizzle schema is the source of truth (apps/memo/src/lib/db/schema.ts).
The drizzle/ chain is a clean baseline — 0000_init.sql + meta/0000_snapshot.json — followed by 0001_slimy_morg.sql (the collaboration tables: project_members + project_invites). The prior chain's snapshots had drifted/become non-linear, so it was collapsed to 0000 and resumed linearly from there. The pre-Phase-1 → current incremental ALTERs (0006–0009) are archived in apps/memo/drizzle-legacy/ with a README. Apply paths:
drizzle-kit push (recommended): diffs schema.ts against the live DB and applies exactly the gap, ignoring the migration files — so it works regardless of applied state. Review before confirming; a second run reports "No changes."0000_init.sql (or drizzle-kit migrate/push). Never apply 0000_init.sql to an existing DB — it CREATEs everything and will conflict; use push or the drizzle-legacy/ ALTERs in order instead.Full detail: Runbook → Database migrations.
After any deploy that includes a schema change, sync the DB (push) BEFORE the new app code rolls. Otherwise routes that reference new columns/tables throw at runtime.
pnpm --filter @matters/memo build (Vercel's monorepo aware-build picks this up via turbo.json).apps/memo/.vercel/output (produced by @astrojs/vercel).memo.matters.ai as the canonical domain. The host gate rejects requests for any other Host header unless ALLOWED_HOSTS is widened./api/* route is a serverless function. Pages under /<slug>/* are also serverless.vercel.json. View status in the Vercel dashboard under Project → Settings → Cron Jobs.memo schema with extensions.memo database (the events collection is created lazily on first beacon).auto. No public access./sign-in and /sign-up and the redirect URL to https://memo.matters.ai/.memo.matters.ai as a verified sending domain.apps/memo/.env.example) in the Vercel project.DEPLOYMENT.md §3.Sentry captures unhandled exceptions on both client and server. The @sentry/astro integration is activated in astro.config.mjs only when SENTRY_DSN_MEMO is set, so dev environments without the DSN don't ship beacons.
Vercel runtime logs show every cron invocation. Filter by path to triage.
Audit logs are queryable via Drizzle:
SELECT ts, actor_user_id, actor_visitor_email, action, target_type, target_id, meta
FROM memo.audit_logs
WHERE ts > now() - interval '1 hour'
ORDER BY ts DESC;
Visitor events (Mongo time-series) are queryable per-link:
db.events.find({ link_id: "<uuid>", ts: { $gte: ISODate("...") } }).sort({ ts: 1 });
Memo ships with a five-step pre-push gate. Every step is also runnable on its own:
| Script | What it catches | Time |
|---|---|---|
pnpm --filter @matters/memo type:check | Type errors across all source files (astro check / tsc) | ~3 s |
pnpm --filter @matters/memo test | Vitest over src/**/*.test.ts (~72 tests). No DB / no network. | <1 s |
pnpm --filter @matters/memo build | Full Astro/Vercel build | ~6 s |
pnpm --filter @matters/memo verify:routes | scripts/verify-routes.mjs — canonical-route presence smoke test | <0.1 s |
pnpm --filter @matters/memo verify | All four chained, gated as one command | ~10 s |
type:check — runs astro check, which is tsc plus Astro's .astro file understanding. Catches everything tsc would catch in a Next.js app, plus drift between Astro.params declarations and route file paths.
test — Vitest is wired in via vitest.config.ts with the same @/* alias that the app uses. Test files live next to their subjects (src/lib/urls.test.ts, src/lib/routing.test.ts, src/lib/validation/link.test.ts, src/lib/visitor/gate.test.ts). Scope is pure modules only — any file that imports a DB / R2 / Redis / Clerk / rrweb-storage adapter is out of the unit-test surface and lives in the route-smoke or live integration runs.
Coverage by file:
| Test file | Coverage |
|---|---|
src/lib/urls.test.ts | ownerRoot, ownerHref (joining + slash normalisation), visitorHref, visitorStepHref, visitorUrl. Includes guards that the legacy /$/ and /v/ schemes don't drift back in. |
src/lib/routing.test.ts | isVisitorPath over the full canonical route set + parity guard between SLUG_RESERVED and the named app surfaces (every named non-visitor path is reserved). |
src/lib/validation/link.test.ts | linkCreateSchema slug rules (kebab-case, lowercasing, min length, reserved rejection, disallowed chars) + self-consistency of SLUG_RESERVED (no dupes, all lowercase). |
src/lib/visitor/gate.test.ts | nextStep state machine across all gate combinations, emailAllowedByLink allow/block precedence, cookieName sanitisation, setCookieHeader flags (Path=/ for root visitor URLs, HttpOnly, SameSite=Lax, Secure only when secure=true). |
build — astro build with the @astrojs/vercel adapter. Surfaces missing/broken imports that type-check wouldn't.
verify:routes — a node script (no deps) that enumerates the canonical route↔file map (134 routes / 134 files at last count) and asserts:
src/pages/.$/, v/, app/ — historical URL schemes).Adding a new page? Update the EXPECTED map in scripts/verify-routes.mjs. The smoke fails loudly if you forget.
verify — the green-light gate. Always run this before git push. Total ~10 s.
| Failure | Likely fix |
|---|---|
urls.test.ts fails | A URL builder drifted off /u/<username> or /<slug>. Check the diff against src/lib/urls.ts. |
routing.test.ts fails on the parity guard | SLUG_RESERVED and isVisitorPath got out of sync. Add the new reserved root to BOTH. |
validation/link.test.ts fails on reserved slugs | Same as above — the reservation list must list every top-level prefix. |
gate.test.ts fails on cookie path | Someone re-scoped the visitor cookie to /v/ or /u/. It must stay / so the cookie is sent on /<slug>/*. |
verify-routes reports orphan page under forbidden segment | A page got created under src/pages/$/, src/pages/v/, or src/pages/app/. Move it into u/[username]/… (owner) or [slug]/… (visitor). |
verify-routes reports missing page for route X | A page file got deleted/renamed but the route map wasn't updated. Restore the file or remove the route from EXPECTED. |
verify-routes reports unexpected page file | A new page exists but isn't in the canonical map. Add it to EXPECTED in scripts/verify-routes.mjs. |
For end-to-end testing against real Neon + Mongo + R2, point .env.local at staging projects (separate Cloudflare bucket, separate Neon database, separate Clerk app). The dev server (pnpm --filter @matters/memo dev) on port 4004 will pick them up. The cron endpoints can be triggered manually:
curl -H "Authorization: Bearer $CRON_SECRET" http://localhost:4004/api/cron/rotate-jwt-key
curl -H "Authorization: Bearer $CRON_SECRET" http://localhost:4004/api/cron/expire-links
Free-tier limits we've designed against:
Above these thresholds memo gets cheaper to upgrade than to engineer around.