A link shares one project behind one access policy. A space shares many projects behind one access policy — a branded virtual data room. A visitor clears the space's gate once (email / OTP / password / NDA), then browses every project attached to the space without re-gating per project.
Spaces live at the /s/<spaceSlug>/ URL prefix — disjoint from link slugs (/<slug>/) and the owner area (/u/<username>/…). s is in SLUG_RESERVED so no link can collide with the space namespace.
Typical use cases: a fundraising data room (deck + model + cap table + diligence docs), an M&A room, a board pack, or a sales room — each a curated set of projects under one branded, gated, watermarked, audited entry point.
| Piece | Where | Purpose |
|---|---|---|
| Space | spaces table | The room: slug, name, branding, gate config, expiry |
| Membership | space_projects table | Which projects are in the room + their display order |
| Visitor groups | space_visitor_groups / space_group_emails | Named cohorts of allowed emails (optional) |
| Access trail | space_audit_logs table | Immutable "who viewed what, when" ledger |
| Diligence | diligence_items / diligence_comments | Optional due-diligence checklist per room (see Teams & Diligence) |
| Gate | lib/visitor/space-gate.ts + lib/auth/space-visitor-jwt.ts | The access state machine + the per-space JWT |
| Native viewer | /s/<slug>/p/<projectId>/ | Streams a project's published version under the space gate |
| Owner dashboard | /u/<username>/spaces + /u/<username>/spaces/<id> | List, detail, analytics, management |
Defined in apps/memo/src/lib/db/schema.ts; see the full Data Model page for column-level detail. The short version:
The room itself. Mirrors the links access-control surface so the gate code can be shared between links and spaces (see Visitor Gates → shared factories).
slug citext unique — globally unique, case-insensitive (matches links.slug so Acme and acme can't both exist — squatting prevention on a public confidential-doc entrypoint).owner_id → users.id ON DELETE CASCADE.name, description, branding jsonb ({ primaryColor, fontFamily, logoUrl }).links): require_email, verify_email, password_hash / password_salt (argon2id), allow_emails[] / block_emails[] / allow_domains[] / block_domains[], require_agreement_id → agreements.id ON DELETE SET NULL, watermark, allow_downloads, expires_at, active.Join table — one row per project in the space.
space_id → spaces.id CASCADE, project_id → projects.id CASCADE.position int — display order in the room (drag-to-reorder writes this).folder_id uuid? — optional grouping pointer (soft; no folders table yet).(space_id, project_id) — a project can't be attached twice.The data-room compliance trail — bigserial PK, space_id CASCADE, actor_email, action, target_type, target_id, meta jsonb, ts. The native viewer appends a project.viewed row on every served project (best-effort; a logging failure never blocks the document).
Spaces reuse the link gate's state-machine shape via the shared helpers in lib/visitor/gate.ts, wrapped by lib/visitor/space-gate.ts:
loadSpace(slug) → { status: "ok" | "not_found" | "inactive" | "expired", space }
readSpaceClaims(cookie, slug) → SpaceVisitorClaims | null (verifies the per-space JWT)
spaceNextStep(space, claims) → "email" | "otp" | "password" | "nda" | "view"
The gate-step order is identical to links: requireEmail → email, verifyEmail → otp, passwordHash → password, requireAgreementId → nda, else view. The POST handlers at /api/s/<spaceSlug>/{email,otp,password,sign} are built from the same factories as the link handlers (createEmailHandler, createOtpHandler, …) — see Visitor Gates.
lib/auth/space-visitor-jwt.ts mints an HS256 token signed with the same daily-rotated Upstash key as the link JWT (currentKey() / previousKey() are shared), but with a distinct claim shape:
type SpaceVisitorClaims = {
sub: string; // visit id — reused as the visits.id PK
spaceSlug: string; // bound to the space (see below)
email?: string;
email_verified?: boolean;
password_passed?: boolean;
nda_signed?: boolean;
};
The cookie is memo_vs_<spaceSlug> (link cookies are memo_v_<slug>), HttpOnly, SameSite=Lax, Secure on https, 24h.
Slug binding (defense-in-depth). readSpaceClaims rejects a token whose spaceSlug claim doesn't match the requested slug — so a token minted for space A can never be honored for space B even if its cookie reaches B's handler. Link claims are bound the same way (claims.slug === slug). Cookie naming already isolates by slug; this is the belt to that suspenders.
Why this exists. An earlier implementation redirected a gated space visitor to the project's standalone
/<linkSlug>/URL. That leaked a separately-gated public link and meant the space's gate granted no real protection — the content was governed by whatever gate that link happened to have. The native viewer serves content inside the space's access boundary instead, and the link slug is never exposed.
Two routes serve space content, mirroring the link viewer but gated on space claims:
| Route | File | Serves |
|---|---|---|
/s/<slug>/p/<projectId>/ | src/pages/s/[spaceSlug]/p/[projectId]/index.astro | The project's index.html (current version), streamed from R2 with chrome injected |
/s/<slug>/p/<projectId>/<...path> | src/pages/s/[spaceSlug]/p/[projectId]/[...path].ts | Per-asset proxy for relative refs (<img src="hero.png">) |
Both enforce, in order:
loadSpace(slug) → inactive ⇒ 403, expired ⇒ 410, not_found ⇒ 404.spaceNextStep(space, claims) === "view" — otherwise redirect to the next gate.resolveSpaceProjectVersion(spaceId, projectId) confirms the project is attached to this space and has a published current_version_id. Anything else is a 404 (existence is not revealed).The entry HTML is run through injectVisitorChrome() with scope: "space": watermark (space visitor's email), screenshot-protection CSS, download-disable (unless allow_downloads), branding CSS vars. The Q&A widget is suppressed for spaces (it targets a link endpoint), and the beacon/recorder only fire when there's a session (see below).
The trailing-slash canonical (/s/<slug>/p/<id> → 308 → /s/<slug>/p/<id>/) is load-bearing: it makes relative asset refs resolve under the project directory and hit the [...path].ts catch-all.
Space views produce real visits rows — the same engine that powers link analytics, so completion %, duration, and engagement signals all work.
The visits table is scope-aware (apps/memo/drizzle/0000_init.sql): link_id is nullable, and space_id + project_id carry the space-visit context. A space view inserts a visit with id = claims.sub, space_id, project_id, link_id = NULL, plus geo + the memo_vp persistent-visitor cookie — ON CONFLICT DO NOTHING so the same 24h session doesn't duplicate. On a new row it fires the visit.opened webhook (with scope: "space") and writes the space_audit_logs entry.
The beacon (/api/beacon, public/beacon.js) is scope-aware: the viewer injects a <meta name="memo-scope" content="space">, the client posts scope in its batch, and the endpoint validates the space gate (instead of the link gate) before recording. Completion %, duration, engagement score, and skim / deep_read / video_watched signals are computed identically — see Observability. The link path is byte-identical; the space path is a parallel context resolver.
Engagement requires an identified session (a space JWT
sub). A fully-open space with no gates has nosub— content is still served, but no visit is recorded and the beacon is not injected (it couldn't be validated without a token).
Space visits get full self-hosted rrweb replay, reusing the existing owner player. See Replay.
visit_recordings.link_id is nullable + space_id added. The R2 blob is keyed by recordings/<projectId>/<visitId>.json.gz — scope-agnostic — so the owner replay loader (/api/owner/recordings/<visitId>, project+visit+owner scoped) works for space visits unchanged.buildRecordingSnippet) carries scope: "space" + spaceId; /api/recording/rrweb/events validates the space gate and stores the row with space_id (and link_id = NULL)./u/<username>/projects/<projectId>/activity/<visitId>/replay player (which is project+visit+owner scoped and link-agnostic).Spaces is a first-class SideRail nav item (layers icon).
/u/<username>/spaces#src/pages/u/[username]/spaces/index.astro. KPI strip (spaces, projects attached, space-native visits, unique visitors) + a table — one space per row: name + /s/slug, project count, visits, unique visitors, last activity, status badge (active / disabled / expired), and Manage / Open ↗ actions. Counts come from grouped queries (no N+1).
/u/<username>/spaces/<id>#src/pages/u/[username]/spaces/[id]/index.astro. The management hub:
space_audit_logs trail.active), Delete.Two drawers on the detail page drive the owner API directly:
POST …/projects), remove (confirm → DELETE …/projects?projectId=…, detaches only — the project is untouched), and reorder via native drag-and-drop with ↑/↓ button fallback (each reorder PUTs the full ordered set, which is validated against existing membership — see the IDOR note below).PATCH …/spaces/[id] form: name, slug, description, branding color + logo, gate toggles, NDA <select>, password change-or-clear, expiry, and allow/block emails & domains (one-per-line textareas → lowercased arrays).GET /api/owner/analytics/space?spaceId=<id> (src/pages/api/owner/analytics/space.ts) returns true per-project engagement from space-native visits:
{
"spaceId": "…",
"projectMetrics": [
{ "projectId", "projectName", "totalVisits", "uniqueVisitors",
"avgCompletionPct", "avgDurationMs", "avgEngagement", "lastVisitAt" }
],
"totals": { "totalVisits": 0 }
}
It LEFT JOINs visits ON visits.space_id = :spaceId AND visits.project_id = projects.id, so a project with zero views still appears (zeroed). Owner-scoped: the space must belong to the caller.
All owner endpoints are behind withOwner and scoped to the caller; all [id] routes re-confirm ownership before read/mutate. See the API Reference for request/response detail.
| Method + path | Purpose |
|---|---|
GET/POST /api/owner/spaces | List / create (slug-clash check, argon2 password, space.created webhook) |
GET/PATCH/DELETE /api/owner/spaces/[id] | Read / update / delete one space |
GET/POST/PUT/DELETE /api/owner/spaces/[id]/projects | List / attach / reorder / detach members |
GET /api/owner/spaces/[id]/search | ilike full-text over attached project file paths |
GET /api/owner/analytics/space?spaceId= | Per-project engagement |
GET /api/owner/space-templates | Static room templates (fundraising / M&A / board / sales) |
POST /api/s/[spaceSlug]/{email,otp,password,sign} | Visitor gate steps (shared factories) |
GET /api/s/[spaceSlug]/project/[projectId] | 303 → native viewer (legacy indirection, gate + membership enforced) |
PUT …/spaces/[id]/projects originally re-inserted any projectIds verbatim, letting an owner attach another owner's project. It now requires the supplied set to equal the space's existing membership exactly (reorder-only) and preserves folder_id. Attaching is POST-only and validates projects.owner_id = caller.resolveSpaceProjectVersion, so guessing a projectId that isn't attached yields 404.signatures.space_id (not link_id, which FKs to links) — see Visitor Gates → signing.