TLDR: Where every backend, frontend and tooling concept lives, the four Drive layers, the package boundaries (
libnever importsuiorsheet; the backend imports lib only through React-free subpaths) and the pitfalls that have bitten more than one domain. AGENTS.md holds the rules an agent needs before its first edit; § Package boundaries and § Pitfalls give the reasons behind them.
Eigen is one API server (apps/api) and a set of web apps (apps/*), which share the code in packages/: lib for the types, hooks and logic both sides use, ui for the shared components and sheet for the spreadsheet. The API keeps each user's and team's data in a Home (STORAGE.md). The apps reach it through Eden, typed from the Elysia routes, with TanStack Query hooks for reads and writes and SSE for live changes (SSE.md). Read the tables to find where a concept lives and which doc explains it. Read the boundaries and pitfalls before your first change in an area you have not worked in: they are the rules a change in one domain most often breaks in another.
| Concept | Location | Pattern |
|---|---|---|
| Home singleton | apps/api/src/lib/home/home.ts |
Per-user instance managing DB connections + domain services. Subclasses: UserHome, TeamHome, OrgHome, GuestHome |
| Domain classes | apps/api/src/lib/[domain]/[domain].ts |
Business logic (Drive, Mail, Contacts, Calendar, ChatRoom) |
| Routes | apps/api/src/routes/[domain].ts |
Thin Elysia routers, {auth: true} for protected |
| DB schemas | apps/api/src/lib/[domain]/schema.ts |
Drizzle ORM schemas |
| DB config | apps/api/src/lib/[domain]/db-config.ts |
DatabaseConfig with versioned migrations |
| ManagedDatabase | apps/api/src/lib/core/managed-database.ts |
WAL mode, versioning, auto-sync, dirty tracking |
| Blob store | apps/api/src/lib/core/blob-store.ts |
The SQL-free half the CalDAV and CardDAV stores share, over one rule: the stored blob is the truth and every other column a projection of it. It holds the resource-name rule, the content-hash etag, the sync-generation seed, the preconditions both write paths evaluate and the store result types. See CALENDAR.md and CONTACTS.md |
| Instance lock | apps/api/src/instance-lock.ts |
One API process per data folder: index.ts takes it, then loads server.ts, before anything opens a database. The lock itself is lockDataDir() (apps/api/src/lib/config/data-lock.ts), which the swap of ./eigen restore takes too, so it refuses while Eigen holds data/. Beside it, a heartbeated lease in data/server/instance.lease refuses an API the lock cannot reach, such as one on the host beside a container. See DATABASE.md § One API process owns a data folder and § A lease refuses the second API the lock cannot see |
| Collab storage | apps/api/src/lib/collab/ |
One CollabDocument per open doc; Yjs updates + snapshots as zstd BLOBs in data.db. Every socket gets the home's data epoch before the sync, so a tab from before a restore reloads (SSE.md). See COLLAB.md |
| Storage backends | apps/api/src/lib/storage/ |
Two classes: LocalStorage (serves both local + local-key modes) and S3Storage; deadline.ts bounds every metadata call and every storage read the server consumes itself; served files (/download and /embed in lib/drive/serve-file.ts, WebDAV GET in lib/webdav/resource.ts) stream unbounded |
| Errors | apps/api/src/lib/core/errors.ts |
throw new ApiError(status, message) |
| Public routes | apps/api/src/routes/public.ts |
/p/* is the deliberate unauthenticated surface: avatars, the public config, invites, the waitlist and public user info, plus the demo's /p/demo/enter. The prefix is the contract: never add auth to one |
| XML reads | apps/api/src/lib/core/xml.ts |
parseXml(text or bytes): the one XML reader, over Bun.XML's tree shape (document order kept). It refuses a DOCTYPE before Bun sees it, returns null for a blank body, resolves every element to {ns, local} (xmlChild, xmlChildren, xmlAttr match by namespace, never by prefix) and throws one XmlError (an ApiError 400) for anything it won't read. serializeXmlChildren writes client content back as XML, its namespaces declared. Writing XML is by hand |
| OOXML core | apps/api/src/lib/ooxml/ooxml.ts |
The folder's overview, layering, examples and safety model: ooxml/README.md. What every OOXML format's reader and writer share: the namespaces, parseOoxml (a part read with its Strict namespaces as transitional, wherever it declares them), markup compatibility (isAlternateContent, alternativeFor, which takes the prefixes the format's reader understands, so each format passes its own list, ooxmlElements, an element's children with each mc:AlternateContent read through that list, and ooxmlChild, the first child of a name read the same way), the units (EMU_PER_PX, EMU_PER_POINT and POINTS_PER_UNIT, Strict's universal measure), the lengths read in them (emu a coordinate, measure a docx's twips or half-points, either none past 64 characters), isOn and isOnElement, a CT_OnOff or CT_Boolean element, bare meaning on |
| OOXML packages | apps/api/src/lib/ooxml/opc.ts |
The package conventions every OOXML format shares: relsPathOf (a part's relationships part), relationshipsOf (a relationships part to its map, an internal target resolved against the source part, an absolute one against the package root, . and .. resolved in both so no .. survives a format's prefix check, an External one kept as written) and relOfType. openPackage opens a package: the encrypted-package check with the format's own 400, [Content_Types].xml, and a part cache that parses each part once and keeps a failed part's error. Every part it parses, [Content_Types].xml included, is charged against one parse budget the formats share, its declared size against MAX_PACKAGE_XML_BYTES before it inflates and its tags against MAX_PACKAGE_XML_TAGS before it parses, past which the whole file is a 413 in the format's words. optional makes a damaged part cost only what it adds, and the package's readMedia reads a picture's entry through it. Part is a parsed part with its relationships, as the docx and pptx readers pass one. The docx, xlsx and pptx readers (import/doc/package.ts, import/sheets/package.ts, import/slides/package.ts) open their packages through it, and the docx and xlsx writers name a part's relationships part with it. The docx, xlsx and pptx writers package their parts with writePackage: [Content_Types].xml (the relationships and XML Defaults, the format's own after them, an Override for each typed part in order), _rels/.rels to the main part and docProps/core.xml with the title, then the parts, each under the XML declaration, then the format's files as given. relationshipsXml writes a relationships part, each Id rId and its place, the target escaped. partRelationships gives a part being written its relationships: relate(type, target, external?) returns the next id the first time a type and target are related and the same id after, so every writer takes its ids from it rather than counting places, the xlsx worksheet's links aside, which Excel relates once per cell. Relationship is one type both ways: read, an internal target is the part name it resolves to; written, the target as the source part names it. Where a format's parts sit (word/, xl/, ppt/) and its content and relationship types stay in the format's module |
| OOXML read budgets | apps/api/src/lib/ooxml/budget.ts |
budget(limit, tooLarge): one of a reader's caps for a crafted file, in units of work. charge is work the file can't be read without, a 413 in the format's words past the cap; spend is work that may be left out, such as a chart, done only when what is left covers it, and affords asks whether what is left covers some work, without charging it. Every running total the docx, xlsx and pptx readers and openPackage charge as they read is one; a bound checked once, such as the docx weigh after its walk or a cache's size, stays a comparison. What a reader leaves out is counted by lossCounter (document/transform/protocol.ts), which returns the warnings an import's summary phrases |
| DrawingML | apps/api/src/lib/ooxml/drawingml.ts |
The drawing vocabulary docx, xlsx and pptx share. drawingColor(fill, theme) resolves the colour element inside a fill (srgbClr, schemeClr through a ColorMap onto the theme's ColorScheme, sysClr by its lastClr, prstClr, scrgbClr, hslClr) with its lumMod, lumOff, satMod, tint, shade and alpha to #rrggbb or #rrggbbaa; transitional and Strict percentages alike (percent). An angle counts in DEGREEs of 60,000 and a share in 100,000ths of HUNDRED_PERCENT, both ways, and the writers convert through drawingAngle and drawingShare. readColorScheme reads a theme part's slots (SCHEME_SLOTS), readColorMap a clrMap or c:clrMapOvr over IDENTITY_COLOR_MAP, drawingTheme a theme part no colour map overrides, and drawingLines a text body's plain text, a line per paragraph or break. A picture's blip both ways: blipXml writes one, its svgBlip extension beside the raster, and blipEmbeds reads the parts one names, the SVG's first, for the docx and xlsx readers (the pptx reader looks through its own charged lookups). DrawingML's text and line vocabulary both ways: a paragraph's algn (ALGN_BY_ALIGN, ALIGN_BY_ALGN), a body's anchor, a line end's type and a preset dash (strokeStyleOf), which the pptx writer and reader share. The theme content type (THEME_TYPE) and themeXml, the plain theme the xlsx and pptx writers write, sit here too, and anchoredBox, the cells a turned picture anchors over in a spreadsheet, a sideways one (isSideways) by its box turned a quarter. IMAGE_EXTENSION_BY_MIME is a picture part's extension by its type, for the readers and the writers alike |
| OOXML charts | apps/api/src/lib/ooxml/ooxml-chart.ts |
readChart(chartSpace, { theme, data }): a DrawingML chart part, the same in docx, xlsx and pptx, to { spec } through parseChartSpec, or { unsupported: { title, name } } for anything the ChartSpec can't draw as the file does (a stacked line, area, scatter, combined groups, secondary axes, a log or reversed value axis, more than the chart limits), name being PowerPoint's for the chart's type as Insert Chart lists it (Clustered Column, Pie of Pie, Combo). data: 'cache' reads the cached values a docx or pptx carries; { ranges } turns an xlsx's c:f refs into CellRangeRefs by the importer's sheet ids, a defined name or a multi-area ref being no spec. Point counts are checked against the limits before anything is built. The chart-wide c:txPr Latin typeface becomes the spec's chart.fontFamily through bundledFont, a theme font being Inter. The data stays in the file's order: an axis Excel runs the other way from Google's, a bar chart's minMax one up from the bottom, reads as axes.x.scale.reversed, which turns the whole axis, bands included, and a deleted axis or one with tick labels none as visible: false. The pptx, docx and xlsx imports call it (import/slides/chart.ts, import/doc/drawings.ts, import/sheets/drawing.ts), each reading the chart part, theme and anchor itself; the xlsx import reads the refs first and falls back to the cache. readChartPartLook reads the look the writer maps onto a part (background, label color, stroke, a line series' width), which all three imports store. Its inverse is writeChart (ooxml/ooxml-chart-writer.ts), the text and line widths at the file's scale (ChartFrame), which the xlsx export writes every chart with through addChartPart, and the docx and pptx exports through addOfficeChart (export/office-chart.ts), which embeds each chart's numbers in a workbook of its own for Office's Edit Data (CHARTS.md) |
| Word vocabulary | apps/api/src/lib/ooxml/wordml.ts |
What the docx writer writes and the docx reader recognises, in one place: WordprocessingML's content type prefix (WML), style names (headingStyleName and headingLevel, codeBlockStyle and codeBlockLanguage), a paragraph's justification (JUSTIFICATION, textAlignOf), list formats and Word's nine levels, spellNumber, the checkbox glyphs, Word's 63 columns and its units. It sits in ooxml/ with the OOXML every format shares, so export/ and import/ never import each other |
| Excel vocabulary | apps/api/src/lib/ooxml/spreadsheetml.ts |
What the xlsx writer writes and the xlsx reader recognises, in one place: SpreadsheetML's content type prefix and a drawing part's type, the 1900 and 1904 date systems, a sheet's default column width and row height and the digit widths that turn columns into pixels, Excel's limits and the characters a sheet name refuses, the built-in number formats, a font category's <family> number, the _xHHHH_ escapes, a rule's numeric operand and cellIs operators, the data-validation types and operators both ways, and the functions Excel added after 2007, whose _xlfn. prefix the reader drops and the writer adds back. It sits in ooxml/ beside wordml.ts, so export/ and import/ never import each other |
| PowerPoint vocabulary | apps/api/src/lib/ooxml/presentationml.ts |
What the pptx writer writes and the pptx reader recognises, in one place: PresentationML's content type prefix, the presentation, slide, layout and master content types, a slide size's range in EMU (MIN_SLIDE_EMU, MAX_SLIDE_EMU, which a text margin's cap shares), and the on-screen show types a 10 in deck names in its p:sldSz. A deck's EMU size is its own width (SLIDES.md). It sits in ooxml/ beside wordml.ts, so export/ and import/ never import each other |
| Zip reads and writes | apps/api/src/lib/ooxml/zip.ts |
openZip(bytes): a zip reader for untrusted uploads, stored and deflated entries, ZIP64, one disk. It refuses more than MAX_ZIP_ENTRIES entries or MAX_DECOMPRESSED_BYTES declared before it inflates anything, inflates an entry only when it is read and never past its declared size, then checks the length and CRC-32. Its end record is the last in the final 64 KB, and its comment reaches the end of the file; as Info-ZIP, Office and java.util.zip read some real files, one without ZIP64 may leave bytes after it when its own fields put the directory right before it and the first entry at the start. Anything it won't read (a second end record or an end-record signature after the end, plain end-record fields that differ from the ZIP64 ones, a comment past the end of the file, a prefix, overlapping or duplicate entries, encryption) is one ZipError, an ApiError 413 past a cap and 400 otherwise. Names stay verbatim and are found only by their exact name, so a ../ or a backslash aliases no other entry. writeZip writes our own archives, dated the DOS epoch, so one input zips to the same bytes. An entry is deflated unless stored: a whole one by libdeflate, smaller and faster than zlib; one given as chunks (writePackage takes a part's XML as chunks too), for a part too large to hold whole, by zlib a chunk at a time, each ended by a sync flush, as libdeflate deflates only whole buffers. Inflating is synchronous, so it runs in a transform process. The docx, xlsx and pptx readers and writers use it alone |
| Window limiter | apps/api/src/lib/core/window-limiter.ts |
WindowLimiter(windowMs, max): an in-memory sliding window per key, behind protocol auth failures, demo entry and the access-request mail. take checks and records, release empties a key. The OTP limiter keeps its own, since a refund takes one email's hits out of a shared IP bucket |
| SSE emission | apps/api/src/lib/[domain]/sse-events.ts |
home.broadcast(buildEvent(...)) |
| Notifications | apps/api/src/lib/notification-center/ |
home.notify({...}): per-user SQLite, dropped on a team home, broadcasts notification:created, which the frontend shows as a toast. See NOTIFICATION-CENTER.md + ACTIVITY-ROWS.md |
| Auth | apps/api/src/lib/auth/auth.ts |
better-auth with org/team/2FA/API key plugins. users3.db has no migration system: a new auth column goes in auth-schema.ts, the setup.ts DDL and ensureAuthSchemaColumns (boot-time ALTER for existing installs) together, because better-auth refuses to start on a Drizzle schema that misses a plugin field |
| Protocol auth | apps/api/src/lib/auth/protocol-auth.ts |
verifyProtocolAuth(): shared IMAP/CalDAV/CardDAV/WebDAV auth (app password → primary password fallback) |
| Trusted networks | apps/api/src/lib/auth/access.ts + trusted-networks.ts |
requireLocalhost() admits a direct caller from loopback or TRUSTED_NETWORKS and refuses anything Caddy proxied; scopeTrustedNetworks() leaves out the bridge gateway a user on the host arrives from, read from /proc/net/route on first use, the rule docker/postfix/entrypoint.sh applies to Postfix. See MAIL.md |
| ACL + sharing | apps/api/src/lib/drive/acl.ts + acl-propagation.ts |
Per-path ACLs, additive down the folder tree; the route takes a delta merged on the server, and the push to recipients is async while every check stays with the owner. See ACL.md |
| Guests | apps/api/src/lib/auth/guest-auth.ts + apps/api/src/lib/home/guest-home.ts |
Sign-in by emailed code, and a GuestHome with only drive and notifications; the share registry (lib/share/) rebuilds a guest's shares on every sign-in. See GUEST-ACCESS.md |
| Organization + teams | apps/api/src/lib/org/ + apps/api/src/lib/team/ |
One organization per server; teams are flat ACL groups and own a TeamHome; an owner id carries its type as a team_ or org_ prefix. See ORGANISATIONS-AND-TEAMS.md |
| WebDAV | apps/api/src/lib/webdav/ |
RFC 4918 Class 1+2 server at /webdav/:ownerId/:mountId/*; mirrors the CalDAV layer. See WEBDAV.md |
| CardDAV | apps/api/src/lib/carddav/ |
RFC 6352 at /dav/addressbooks/:ownerId/…, over the contacts store, where a card's vCard bytes are the truth. The vCard parser, serializer and transcoder are backend-only, in apps/api/src/lib/vcard/. See CARDDAV.md and CONTACTS.md |
| CalDAV | apps/api/src/lib/caldav/ |
RFC 4791 at /dav/calendars/:ownerId/…, the protocol layer over the calendar store, where a resource's iCalendar bytes are the truth. The iCalendar parser and builder every .ics surface shares (the store, iMIP, import, export, quick look) are in apps/api/src/lib/ical/. See CALDAV.md and CALENDAR.md |
| Calendar invitations | apps/api/src/lib/calendar/invite-propagation.ts + invitations.ts + imip.ts |
An attendee's linked copy carries the organizer's X-EIGEN-ORGANIZER-* stamp; relayed and mailed REQUESTs take one locked decision, ordered by SEQUENCE then DTSTAMP, and inbound iMIP acts only on a DKIM-aligned sender our own MTA verified. See CALENDAR.md |
| Shared DAV core | apps/api/src/lib/dav/ |
What the DAV routers share. All three read a request body through readBoundedBodyBytes (lib/core/http.ts), each with its own cap, and check a PROPFIND body with parsePropfind; body.ts holds only DAV_BODY_MAX_BYTES, the cap CalDAV and CardDAV share. CalDAV and CardDAV also answer identically from it: the XML envelope and principal props, the PROPFIND prop selection, the store-result to HTTP status table, sync tokens, hrefs and multiget, a REPORT's hrefs, sync-token and UnsupportedFilterError (report-request.ts), and the byte-budgeted REPORT row. See CALDAV.md |
| Server config | apps/api/src/lib/config/server-config.ts |
Identity + secrets: secret made at first boot; orgName, orgId, setupCompleted and mailDomain written at setup, and only orgName changes after (PUT /settings/organization); the web address is the DOMAIN env var |
| Setup token | apps/api/src/lib/setup/setup-token.ts |
The one-time token in the setup link: only its sha256, in hex, in data/server/setup-token. /setup/s3check, /setup/s3harden and /setup/complete refuse without it (403, before any S3 call or write); the control socket's POST /setup-link (what ./eigen setup ends with) replaces it, a development boot logs one, completing setup deletes it |
| Control socket | apps/api/src/routes/control.ts |
The CLI's online commands (./eigen status, setup, reset-password, backup, restart-notice, which update, rollback and restore run, and status --running, which every command that stops Eigen asks first) reach the running API over a 0600 Unix socket (getControlSocketPath()), never the web. controlRouter is a thin router over getServerStatus() (lib/config/server-status.ts), createSetupLink(), resetUserPassword(), startServerBackup() and announceRestart() / clearRestartNotice() (lib/config/restart-notice.ts, see SSE.md). The owner's GET /settings/status serves the same getServerStatus() to the Settings page |
| Operator CLI | eigen + apps/api/src/cli/ |
./eigen, a POSIX sh launcher on the host, does every Docker step, since no container gets the Docker socket. The Bun CLI in the API image (cli/index.ts lists its commands) does the rest and answers the launcher in key=value lines; exit 3 (DECLINED) means the operator said no. apps/index/public/install, served at eigen.is/install, fetches the launcher and runs setup, or restore on a new machine. The launcher keeps its state in .eigen/ (lock, bundle, last-update, restore-swap, restore.lock, mirror.env, networks.json, last-step.log). A folder with docker-compose.build.yml, such as a clone, is a local build: setup builds its images and pins none, and update and rollback refuse. See SELF-HOSTING.md |
| Docker images | docker/api, docker/frontend, docker/postfix, docker/dovecot, docker/unbound (Dockerfiles), docker-compose.yml, .github/workflows/publish.yml |
Five images, ghcr.io/eigen-is/eigen/<name>, built for linux/amd64 and linux/arm64 on every release and as the :main channel on every push to main. Each carries its version and commit as labels (org.opencontainers.image.version, .revision), and the launcher names a build by the api image's, <version> (<revision>). Each is pinned by its index digest in .env.production. A local build builds the same five through docker-compose.build.yml |
| Release pins | apps/api/src/lib/config/release.ts |
PIN_KEYS: the .env.production keys that pin a release (its registry, its version and its five images), read by the CLI that pins them and the server backup that records them. API_IMAGE_KEY: the key of the API image, which names the build a pinned install runs. IMAGES in ./eigen is the launcher's copy of the image names |
| Whole-server backup | apps/api/src/lib/backup/server-job.ts + apps/api/src/routes/server-backup.ts |
The owner's backup of every home plus the server's databases, .env.production, the DKIM key and the mail TLS certificate into one plain tar in backups/, run inside the API from Settings, the schedule tick or ./eigen backup. server-job.ts runs the backup and lib/backup/upload-job.ts its upload to the bucket, lib/backup/server-archives.ts lists, deletes, prunes and recovers the records and gives the status, and lib/backup/schedule.ts holds the tick; retention in lib/backup/retention.ts. See BACKUP.md § The whole-server backup runs inside the API |
| Backup upload | apps/api/src/lib/backup/upload.ts |
A verified server archive to a private bucket of its own, checked before every upload and pruned by its own count. See BACKUP.md § Upload goes to a bucket of its own |
| Offline restore | apps/api/src/cli/restore.ts, restore-stage.ts, restore-swap.ts, restore-install-copy.ts + apps/api/src/lib/backup/restore-server.ts |
./eigen restore stages a server archive into data/.restoring while Eigen runs, then swaps it in as root under the .eigen/restore-swap marker. The stage runs beside the live API, so no module that cli/restore.ts loads, restore-server.ts included, opens a file on load. restore --check-data is the check ./eigen rollback has the build before run on today's data first: it keeps the data when that passes, and again with --since once Eigen is stopped, and restores the update's Full backup when it fails. The swap moves in root's copies of the DKIM key and the TLS certificate (restore-install-copy.ts), with the owners the ones here have. See BACKUP.md § ./eigen restore stages while Eigen runs, then swaps under a marker |
| Server settings | apps/api/src/lib/config/server-settings.ts |
Runtime-adjustable quotas, storage defaults, onboarding, guests, the system sender. Changing them is the owner's (requireOwner). See SERVER-SETTINGS.md |
| Quota resolution | apps/api/src/lib/config/quota.ts |
resolveUserQuotas(): the mount's own maxSizeMB, else the server default, against team overrides (most permissive wins) |
| Quota enforcement | apps/api/src/lib/config/enforcement.ts |
getUploadMaxSize, enforceAvatarUpload |
| Mailer | apps/api/src/lib/mailer/ (mailer.ts, mail-template.ts, mail-composers.ts) |
sendMail(OutboundMail): to the bundled Postfix with hosted mail, else straight to the one relay key set SMTP_RELAY_* (Postfix relays through the same keys); buildMailOptions() applies the one sender rule: the person as From when they may send as themselves, else the system sender (mail in settings.json) with a "via" name. Skips in development when no SMTP host is set and always in demo mode; supports replyTo/attachments/envelope/messageId/threading (inReplyTo + references). The folder imports only config and core, so any domain can send mail without an import loop. See SERVER-SETTINGS.md § Hosted mail and the relay are environment, not settings |
| Environment | apps/api/src/lib/config/env.ts |
isProduction() (PRODUCTION=1/NODE_ENV=production); isDemo() (EIGEN_DEMO=1), the demo-instance deployment shape, see DEMO_MODE.md; isMailEnabled() (MAIL_ENABLED=0 turns hosted mail off), see SERVER-SETTINGS.md § Hosted mail and the relay are environment, not settings. NODE_ENV is a runtime Compose env, never a Dockerfile build ENV: a build-time production makes bun install skip devDependencies. better-auth's rate limiter is forced on in auth.ts, so it never depends on the flag. The flag does narrow getTrustedOrigins (auth.ts), the origins better-auth and CORS accept: the dev apps' localhost ports are trusted only outside production |
| Singleton factory | apps/api/src/utils/singleton.ts |
createAsyncSingleton() for Home/DB instances |
| Home relay | apps/api/src/lib/home/home-relay.ts |
Cross-home messaging via sendToHome(); reads via pull*(). See SCALABILITY.md |
| Data epoch | apps/api/src/lib/home/data-epoch.ts |
The server's epoch, the build's commit and the home's epoch, announced on every SSE stream and sent on every collab open. A restore or an update changes it, and a tab that holds the old one reloads. See SSE.md § A restore reloads every tab of the home |
| Scheduler | apps/api/src/lib/scheduler/ |
scheduleInterval(name, ms, fn) for in-process periodic jobs, registered in jobs.ts: guest cleanup and the whole-server backup tick |
| Document transforms | apps/api/src/lib/document/transform/ |
Bounded runner + one-shot child processes under the machine's memory for every CPU-heavy transform (previews, exports, imports, search); never a main-thread fallback. See DOCUMENT-TRANSFORMS.md |
| Previews | apps/api/src/lib/preview/ |
Version-keyed preview cache, text and Eigen-document bodies, image and video thumbnails; the .vcf, .eml and .ics quick looks serve typed payloads. See PREVIEWS.md |
| Upload pipeline | apps/api/src/lib/mount/upload-queue.ts + upload-limits.ts |
Write-behind S3 uploads of managed databases (data.db) and of the plain files a per-home restore stages (isDatabase: false, lib/backup/materialize-mount.ts): stage + enqueue, a per-mount UploadQueue drains with retry/backoff; every other plain-file PUT and local/local-key stay synchronous. See SYNC.md |
| Versioning | apps/api/src/lib/versioning/ |
Opt-in file-level snapshots in <container>/versions/; snapshot/restore mechanics + locking in STORAGE.md § Version snapshots live inside the container |
| Per-home backup / restore | apps/api/src/lib/backup/ + apps/api/src/routes/backup.ts |
snapshotHome captures one home at a level, and the admin-only /admin/backup/* routes back up, verify and restore one home; a restore deletes nothing. See BACKUP.md |
| Copy / move | apps/api/src/lib/drive/copy-across.ts |
Move stays in-mount; copy picks the same-storage fast path or the cross-mount bridge. See STORAGE.md § Copy goes anywhere, a move stays in its mount |
| Import from Drive | apps/api/src/lib/drive/import-source.ts |
readImportSourceBytes: the one reader behind every import-from-drive route (drive, mail, contacts, calendar). It resolves the source through getSharedDrive, so an import reads any file the user may read, and reads the bytes under a running cap, because the row's size is only a claim. The rejection strings, and the decodeUtf8Strict contacts and calendar decode with, live in lib/core/transfer.ts |
| File history + watch | apps/api/src/lib/drive/history.ts |
FileHistory on Mount (file_events + path_watchers): typed events, read-gated watcher notifications, drive:file-history-updated SSE. See FILE-HISTORY.md |
| Trash | apps/api/src/lib/mount/trash.ts + apps/api/src/lib/drive/trash.ts |
A delete moves an item to trash and revokes its shares; a restore brings them back. See SOFT-DELETE.md |
| Comments | apps/api/src/lib/chat/ |
A comment is a card in the Y.Doc, a chat thread in the container's chat/ folder and a row in its comments.db; the server writes status and assignee. See COMMENTS.md |
| Mail store | apps/api/src/lib/mail/ |
A per-user Maildir++ that Dovecot serves too; the files are the truth and mail.db an index a sync rebuilds. See MAIL.md and IMAP.md |
| Export + import | apps/api/src/lib/export/ + apps/api/src/lib/import/ |
Every format but xlsx, docx and pptx is one HTML document, and PDF is that document through WeasyPrint; xlsx, docx and pptx have writers of their own, each in a folder beside its type's renderer (export/sheets/xlsx/, export/doc/docx/, export/slides/pptx/), a file per part as its reader has, the entry writeXlsx, writeDocx or writePptx taking the content and { title, media }. A docx comes in through a reader of our own (import/doc/), WordprocessingML straight to eigendoc JSON with every input bounded (import/doc/package.ts); an xlsx through another (import/sheets/), SpreadsheetML straight to sheets, a sheet's grid a batch of rows at a time, with every element it parses charged against a read budget (import/sheets/package.ts) and every cell and character it emits against an output budget (import/sheets/from-xlsx.ts); a pptx through a third (import/slides/), PresentationML straight to slides, with every lookup, colour, run and shape it reads charged against one work budget (import/slides/lookup.ts). The readers say what they refuse in the words of import/messages.ts, whose importError(error, format) is the one mapping of a reader's failure to what the user reads: a zip's or the XML's refusal and a stack the file outgrew in the format's words, an out-of-memory passed through to the transform process, any other throw a reader slip, logged with its format, and a 400. The docx reader recognises its own output by the look values it shares with the writer (document/looks.ts, over document/prose-css.ts). The color normalizer and font stacks every writer shares are document/colors.ts and document/font-stacks.ts, so the looks never import from export/. See EXPORT.md |
The Drive system has four layers. When adding new features, all four need changes:
Route (thin handler) → SharedDrive (ACL wrapper) → Drive (business logic) → Mount (storage + DB)
- Mount (
apps/api/src/lib/mount/mount.ts): Core storage operations on a single mount'smetadata.db. Handles file CRUD, path resolution, storage key building. Three storage backends:local(hierarchical paths),local-key(flat UUID keys),s3(S3-compatible). Trash, copy, search-index and the managed document-DB lifecycle live in siblingmount/*.tsmodules (plain functions over the mount;Mountstays the facade); versioning mechanics inversioning/snapshot.ts - Drive (
apps/api/src/lib/drive/drive.ts): High-level API over multiple mounts. Handles ACL propagation, collab document lifecycle, SSE emission, sharing - SharedDrive (
apps/api/src/lib/drive/shared-drive.ts): ACL-enforcing wrapper, composition over inheritance: it does NOT extend Drive.getSharedDrive()returnsDrive | SharedDrive; routes can only call methods present on both, so adding a public method toDrivewithout a matchingSharedDrivewrapper is a TS error at the callsite. Own-drive routes get raw Drive (no ACL overhead); cross-owner routes get SharedDrive (ACL-checked). Escape hatch: a small number of routes, such as/shared/by-meand/shared/with-me, need owner-only Drive methods that have no meaningful ACL semantics. TheyrequireSelf(params.ownerId, user.id)first and then callgetDrive(user)to obtain raw Drive, bypassing the SharedDrive surface. Every other Drive method without a wrapper serves other server code, such as collab, chat or the home relay. A route that calls one anyway checks access itself, and a comment on the method says how. If you add a route that needs one of those, add a SharedDrive wrapper first, don't reach for the escape hatch - Routes (
apps/api/src/routes/drive.ts): Thin Elysia handlers that delegate viagetSharedDrive
Every Eigen document is a Drive folder with its own data.db and media/. The MIME strings are the DRIVE_MIME_* constants in packages/lib/src/types/drive.ts; never type one by hand (gated). The MIME values and extensions are frozen for every type, stickies or not.
| Type | MIME | Extension | Storage |
|---|---|---|---|
| Document | application/eigendoc |
.eigendoc |
data.db (Yjs) + comments.db + media/ |
| Stickies | application/eigenstickies |
.eigenstickies |
data.db (Yjs) + comments.db + media/ |
| Chat | application/eigenchat |
.eigenchat |
data.db + media/ |
| Slides | application/eigenslides |
.eigenslides |
data.db (Yjs) + comments.db + media/ |
| Sheets | application/eigensheets |
.eigensheets |
data.db (Yjs) + comments.db + media/ |
| Vector | application/eigenvector |
.eigenvector |
data.db (Yjs) + comments.db + media/ |
| Concept | Location | Pattern |
|---|---|---|
| API client | packages/lib/src/core/api.ts |
Eden Treaty, type-safe from the Elysia definitions |
| Data hooks | packages/lib/src/core/[domain]/hooks/ |
TanStack Query with hierarchical query keys |
| SSE handlers | packages/lib/src/core/[domain]/sse-handlers.ts |
useSSE hands each event to its domain handler, which invalidates the query cache. See SSE.md |
| Shared types | packages/lib/src/types/[domain].ts |
Used by both FE and BE |
| Validation | packages/lib/src/validation/ |
Shared FE/BE validation |
| Colors | packages/lib/src/constants/colors.ts |
EIGEN_COLORS, EIGEN_ACCENT_COLORS, and the one reader of a written color: isHexColor with the digit counts a caller takes, parseRgb for rgb() and rgba(), readHexColor for a stored or unknown value to a lowercase #rrggbb or null |
| Fonts | packages/lib/src/constants/fonts.ts + font-slices.ts + scripts/fonts/build-font-slices.ts |
EIGEN_FONTS (the picked text faces, order load-bearing) and FALLBACK_FONTS (emoji, CJK, math); getFontFamily builds every CSS stack, MATH_FONT_STACK the math one. Every face is cut into unicode-range slices by the vendoring script, which writes the WOFF2s, fonts.css and the slice table; slicesFor picks what an export or a downloaded image embeds. See TYPOGRAPHY.md |
| App URLs | packages/lib/src/constants/app-urls.ts |
APP_URLS, the production build's relative per-app URLs (VITE_APP_*_URL), which configure writes for the API; the eigen-doc editors' URL map is a separate fact, EIGEN_DOC_APP_URLS in packages/ui/src/components/drive/eigendoc-config.ts |
| Enabled apps | packages/lib/src/core/public/hooks/use-public.ts |
useEnabledApps(), the apps registry as this server offers it (no Mail without hosted mail), for the app switcher, the Space home and the cycling logo; useMailEnabled() is the one read of mailEnabled. See SERVER-SETTINGS.md § Mail off hides the Mail app and keeps outbound mail |
| Mail off | packages/ui/src/components/mail/mail-off-state.tsx |
MailOffState, the one "Mail is turned off on this server" screen, for the Mail app and the Space mail page |
| Admin settings sections | packages/ui/src/components/layout/app/settings-section.tsx + settings-footer.tsx |
SettingsSection (heading, description, children) and SettingsFooter (the one Save/Reset bar, which also renders a LeaveGuard while the draft is unsaved) of the admin Settings, Onboarding and Guest access pages |
| Owner guard | apps/admin/src/routes/_auth._owner.tsx |
Settings, Onboarding, Guest access and Waitlist are the org owner's; useIsOrgOwner() (packages/lib/src/core/admin/hooks/use-is-admin.ts) is the one read of the role for this guard and the sidebar |
| Server backup UI | apps/admin/src/components/admin/server-backup-section.tsx + packages/lib/src/core/admin/hooks/use-server-backup.ts |
The owner's Settings § Backups: the schedule, the bucket, Back up now and the archive list. Its rows share BackupArtifactRow with the per-home pane (backup-section.tsx) |
| Mail constants | packages/lib/src/constants/mail.ts |
Send caps; DEFAULT_RELAY_PORT, shared by the mailer and configure; defaultSenderAddress() (noreply@<mail domain>), shared by the mailer (setup reaches it through the mailer's storedSender) and the admin setup wizard and Settings page |
| Yjs utilities | packages/lib/src/core/collab/yjs-utils.ts |
restoreYjsDoc (live-state replacement on version restore, COLLAB.md) + stickies' typed root/id-array accessors (STICKIES.md) |
| Menus | packages/ui/src/components/context-menu/menu-sections.tsx |
MenuSections, the one order of every context and ⋮ menu's groups, a destructive row last and red; scripts/check-standards.ts gates the red. See LAYOUT.md |
| Leave guard | packages/ui/src/components/layout/app/leave-guard.tsx |
LeaveGuard (active, title, description): TanStack useBlocker with beforeunload, so a reload, a closed tab or an in-app navigation asks first through the shared ConfirmDialog; UnsyncedEditsGuard (collab) and SettingsFooter (unsaved settings) are its two callers |
| Collab lifecycle | packages/lib/src/core/collab/hooks/use-collab-doc.ts |
useCollabDoc owns the Y.Doc + provider + UndoManager for every collab editor. Gate the loading screen on loaded, never synced. It reconnects with the server's data epoch and reloads the page on COLLAB_HOME_REPLACED_CLOSE, after the commit that disarms UnsyncedEditsGuard. See COLLAB.md |
| Replaced-home reload | packages/lib/src/core/home/ |
handleHomeSSEvent keeps the data epochs a tab holds, and reloadReplacedHome reloads the tab when one changes: the one reload for the SSE announcement and the collab socket's close. See SSE.md |
| Canvas engine | packages/lib/src/vector/ + packages/ui/src/components/vector/ |
One engine for free-canvas documents: React-free element model + kind registry in lib, CanvasEditor the host in ui. See CANVAS.md |
| Canvas image | packages/ui/src/components/vector/canvas-image.ts + packages/lib/src/vector/canvas-image.ts + rich-text-svg.ts |
The SVG the open editor downloads: a copy's payload drawn by the browser, rich text as positioned SVG text from its own layout, only the faces it draws in embedded. See EXPORT.md |
| Canvas arrows | packages/lib/src/vector/binding.ts + elbow-route.ts + elbow-pins.ts |
Bindings stored on the arrow only and re-glued in the shape's own transaction, docking on the drawn outline, derived elbow routes until a segment is pinned. See CANVAS.md |
| Charts | packages/lib/src/charts/ + packages/lib/src/vector/kinds/chart.ts + packages/ui/src/components/charts/ |
One ChartSpec read by readChartSpec as stored and as resolved, written by applying a change onto what is stored, laid out as canvas elements plus labels and painted as plain SVG by the canvas chart kind and the docs chart node; ChartEditor and ChartDataDialog hand any host a change of a spec. See CHARTS.md |
| Canvas editors | apps/vector/ + apps/slides/ |
One engine, two apps: CanvasEditor on the infinite canvas (drawings) and in frame mode (a deck of slides). See CANVAS.md + SLIDES.md |
| Docs editor | apps/docs/ + packages/lib/src/docs/eigendoc/ |
TipTap over the Yjs default fragment; one eigendoc schema serves the editor and every server renderer, a figure stores a media name, a chart its spec and a text box its paragraphs in the figure's frame; a note and a text box are stories, each edited in one live editor of its own and followed by its Yjs element or its id. See DOCS.md |
| Math | packages/math/ |
@workspace/math: one equation tree, with a reader and a writer for Word's OMML, MathML and LaTeX, and a plain-text writer. See MATH.md |
| Sheets engine | packages/sheet/src/engine/ |
DOM-free formula evaluation, the gated server recalc, number display and conditional formats; the API imports only @workspace/sheet/engine, and its leaf engine/a1-notation and engine/format where the formula engine isn't wanted. See SHEETS.md |
| Sheet cell glyphs | packages/sheet/src/state/modules/data-verification.ts + cell-glyph.ts |
Tick boxes, list chevrons and corner marks painted on the canvas, one rect shared by painter and hit test. See SHEETS.md |
| Sheets export | apps/api/src/lib/export/sheets/ |
Class-styled HTML and PDF, the two stylesheet guards, the accepted xlsx round-trip drifts. See SHEETS.md |
| Everyday UI | @workspace/ui root barrel |
Check these before building custom UI: TooltipButton, DeleteDialog, ConfirmDialog, FormDialog with FormDialogForm (a dialog around a small form: Enter submits, and the buttons and every close wait for a pending submit; gated: a dialog footer outside FormDialog is an allow-listed exception), EmptyState/LoadingState/ErrorState, SearchBar, FileMenu, RequestAccessView (the "request access" screen for a shared resource). Everything else: SHARED-PRIMITIVES.md |
| App shell | packages/ui/src/components/layout/app/app-shell.tsx |
Wraps every app (Topbar + sidebar + content) |
| Provider stack | packages/ui/src/components/layout/app/eigen-app.tsx |
Auth → SSE → Upload → Preview → CommandPalette → Toaster |
| Layout | packages/ui/src/components/layout/app/column-layout.tsx |
ColumnLayout + Column with responsive mobile switching; ListDetailLayout for a list whose selection opens a detail |
| Routing | apps/[name]/src/routes/ |
TanStack Router, file-based. _auth.tsx guards |
| Command palette | packages/lib/src/core/command-palette/ |
Mod+K dialog; reach it through useOptionalCommandPalette so apps without the provider don't crash. See COMMAND-PALETTE.md |
| In-document search | packages/lib/src/doc-search/ + packages/ui/src/components/search/ |
⌘F find bar (+ replace) in every eigendoc editor, on one shared 3-method DocSearchController contract. See IN_DOCUMENT_SEARCH.md |
| Search | apps/api/src/lib/mount/search-index.ts + packages/lib/src/core/search/ |
Per-scope inline FTS5: mail + drive name/content indexes; FE useSearchQuery hook. See SEARCH.md |
| Contact suggestions | packages/lib/src/core/contacts/hooks/use-contact-suggestions.ts |
Single canonical hook merging personal contacts + team members, used by mail compose, calendar share/attendees, drive share, chat @-mention, and the command palette. ContactSuggestion shape in packages/lib/src/types/contact.ts |
| New-chat wizard | packages/ui/src/components/chat/chat-create-wizard.tsx |
ChatCreateWizard: two-step "New chat" dialog with open-don't-duplicate matching. See CHAT.md |
| Mail shortcuts | apps/mail/src/components/mail/hooks/use-mail-shortcuts.ts |
Opt-in Gmail-style keyboard shortcuts; ? in Mail opens the cheat-sheet. See MAIL.md |
| Mail list + pagination | packages/lib/src/core/mail/hooks/use-emails.ts |
Keyset-paginated useInfiniteQuery with optimistic per-id cache patches. See MAIL.md |
| Eigen-doc icons | packages/lib/src/core/eigendoc-icons.ts |
EIGEN_DOC_ICONS: the single source for the icon per eigen-doc type. Kept out of types/drive.ts so that file stays type-only on the BE side |
| Drive copy/move | packages/lib/src/core/drive/hooks/writes.ts |
Right-click Move to… / Copy to… / Duplicate via useMovePath, useCopyToFolder (the pickers) and useCopyPaths (Duplicate) + the reused DriveLocationPicker. Editors and chat copy into their media folder with useCopyToMediaFolder. See STORAGE.md § Copy goes anywhere, a move stays in its mount |
| File subject | packages/lib/src/types/file-subject.ts + packages/lib/src/core/file-subject.ts |
FileSubject: the identity of one file a surface can act on, whatever holds it (a Drive item, a mail part); subjectFromPath() and subjectFromMailAttachment() are its only builders, subjectInfo() derives everything that follows (the key, the name, the mime, the size, the URLs) and getPreviewMode() picks the overlay's render mode. See PREVIEWS.md |
| Drive actions | packages/lib/src/core/drive/drive-actions.ts + packages/ui/src/components/drive/drive-action-runner.tsx + packages/ui/src/components/drive/drive-palette-selection.tsx |
DRIVE_ACTIONS + driveActionsFor({ items, capabilities, isSharedWithMe }): each Drive item action's label, group, gate and hotkey, worded by labelFor; useDriveActionRunner runs one and owns its dialogs, DriveActionMenuItem (drive-action-menu-item.tsx) draws one as a menu row, and drive-palette-selection.tsx publishes the same rows to the command palette. See LAYOUT.md |
| File actions | packages/lib/src/core/file-actions.ts |
FILE_ACTIONS + fileActionsFor(subject, exclude?): the one registry of what may be done with a subject; a predicate reads the subject, never which menu is asking |
| File action runner | packages/ui/src/components/file-actions/use-file-action-runner.tsx |
useFileActionRunner(subject, siblings?, exclude?) performs a registry row, hands its host the rows to draw (actions, from useFileActions, the one place that hides a guest-denied row) and one dialogs node to mount, so a picker outlives the menu that opened it |
| Message view | packages/ui/src/components/mail/message-view.tsx |
MessageView: one message's header and body from data alone, drawn by the mail reader and by the .eml quick look. See MAIL.md |
| Event detail card | packages/ui/src/components/calendar/event-detail-card.tsx |
EventDetailCard: one event read-only from data alone (when, recurrence, location, description, guests), drawn by the calendar app's detail dialog and by the .ics quick look. See CALENDAR.md |
| Save to Drive | packages/ui/src/components/drive/save-to-drive-picker.tsx |
SaveToDrivePicker: one "where does this go" dialog for every surface that puts a file into Drive. A Drive subject is copied server-side, a mail part is written from the message the server holds, and "Download instead" falls back to a staggered browser download |
| Import to Calendar | packages/ui/src/components/calendar/import-to-calendar-picker.tsx |
ImportToCalendarPicker: the target an .ics import needs and the other two do not: the viewer's own calendars, the team calendars they may write in, or a new one named after the file, then the import itself. See CALENDAR.md |
| Concept | Location | Pattern |
|---|---|---|
| Domain docs | .claude/skills/domain-doc/SKILL.md |
How a docs/ domain doc is written, rewritten and checked: a TLDR that stands alone, headings that state facts, a why in every section, every claim checked against the code, and the link and anchor checks |
| Help center | .claude/skills/support-article/SKILL.md + apps/index/src/data/support/ |
How an article is corrected or written, each claim checked against the code. Pipeline: HELP-CENTER.md, style: SUPPORT-STYLE-GUIDE.md |
| App build | vite.shared.config.ts |
createAppConfig() is every app's Vite config. Its codeSplitting.groups name third-party packages only: a group takes its modules' dependencies along, so a group for a workspace package such as packages/sheet pulls in ui and lib modules the entry imports, and every page preloads the whole chunk. Workspace code splits by route |
| Release | scripts/release.ts |
bun run release [patch|minor|major] bumps the version and lists the commits for the CHANGELOG; bun run release publish drafts the GitHub Release from the intro of the version's CHANGELOG section |
| Release gate | docker/test-release.sh + .github/workflows/publish.yml |
On a tag, the previous published release is updated to the candidate images and rolled back before they publish; a release that lists breaking changes publishes only with breaking: true. See TESTING.md § A release publishes only the images its gates tested |
AGENTS.md § Critical Rules states these rules in short. Each one follows with its reason.
Package dependency direction is one-way: sheet → lib and ui → lib, never the reverse: lib imports neither. packages/lib is shared FE+BE; packages/sheet declares React peer dependencies, and both packages/sheet and packages/ui are React/DOM-coupled modules. If lib imported either, the BE would transitively pull React in at module-eval time (a biome rule enforces it). Shared sheet types (Cell, Sheet, Op, CellMatrix, Range, SingleRange, ConditionalFormatRule, …) live in packages/lib/src/sheets/types.ts; the sheet package's engine/types.ts and state/types.ts re-export them. Sheet utilities that need to be importable by both FE and BE (e.g. opToPatchOnSheets) live in packages/lib/src/sheets/. packages/math is a leaf below all three: any package or app may import it and it imports nothing, no package and no React, DOM, Bun or Node API (its biome rule and its tsconfig, lib: ["ESNext"] with no types, enforce it; tsconfig.test.json adds Bun's for the tests), so the browser, the API, a Worker and Node run it unchanged and it owns its own types
The backend imports lib through React-free subpaths, never core/ domain barrels: every @workspace/lib/<domain> barrel re-exports React hooks, so importing one from apps/api pulls React in at module-eval. BE-safe by design: types/*, constants(/*), validation, sheets(/*), vector(/*), charts, background, docs/eigendoc, and the React-free leaf modules (content-line, date, deep-merge, format, guards, html, xml). For a React-free module that lives inside a domain dir, lib's exports map carves out an explicit subpath (calendar/calendar-utils, chat/emotes, chat/built-in-emotes, chat/format-preview, collab/yjs-utils, mail/addresses, mail/remote-content, notification/tags): import those, not the barrel. Need a new one? Add the exports entry next to these and keep the module React-free; lib has no wildcard exports into core/, so an uncarved deep import simply won't resolve. download is the counter-example: a leaf subpath too, but it needs document and URL.createObjectURL, so it is FE-only and never BE-safe. docs and docs/eigendoc sit side by side but differ: @workspace/lib/docs is the Docs domain's barrel of React hooks, such as useDocsTextWidth, and never BE-safe, while docs/eigendoc is the React-free schema and page model the backend imports. The resolver itself (what each @workspace/* specifier gives you) is the table in CODE-STANDARDS.md § Imports.
One source of truth per fact: a set, map, schema, or constant that answers a question (which extensions are text-editable? which MIME is a spreadsheet? what's a valid S3 config?) lives in exactly one module. Import it; never re-list its members inline "just for here." Two lists of one fact drift apart. Need a subset? Derive it from the canonical one
A primitive isn't "shared" until its barrel exports it: reusable values go through the package's public entry (@workspace/ui, @workspace/lib/<domain>), reusable types through @workspace/lib/types/<domain>. An unexported primitive is invisible to the next author, who rebuilds it. The inverse is also a smell: deep-importing past a barrel (@workspace/lib/core/…) usually means the thing you reached for should have been exported. And an export needs a user outside its file, which the standards script checks (CODE-STANDARDS.md § Standards Gates)
Third copy → shared wrapper: the "if two+ apps need it, it goes in packages/" rule applies to scaffolds, not just components: route guards, _auth.tsx files, editor shells, loading/empty/error treatments. When you're about to paste one into a third app, stop and extract a single guarded wrapper into packages/ui. createEigenAppRouter is the one bootstrap of every app but the marketing site (apps/index), createAuthRouteOptions the route guard of every app but admin, whose own _auth.tsx also checks the org role, and SidebarSection the one loading, error and empty treatment of every app sidebar
These patterns have caused bugs across multiple domains. The gated ones (MIME strings, :ownerId, hover-without-touch) are enforced by scripts/check-standards.ts; see CODE-EXAMPLES.md § Standards Gates.
- Query keys must include
ownerIdfor any owner-scoped data: CODE-STANDARDS.md § Key Patterns - Add a
SharedDrivewrapper for every route-callableDrivemethod, with the appropriate permission check (requireRead,requireWriteorrequireDriveOwner). See § Drive Architecture - MIME type strings: § Eigen file types (gated)
validateSearchin shared routes must extract all URL params the route uses: missing params (likeuid) silently break detail panes for shared items- Never mutate TanStack Query cache directly: use
queryClient.setQueryData()orinvalidateQueries(), not direct object mutation on cached data - No
"use client"directives: this is a Vite project, not Next.js. The directive is a no-op - Every authenticated route must include
:ownerIdas the second path segment.ownerIdidentifies the Home that owns the resource. For personal data it equalsuser.id; for team data it'steam_{teamId};parseOwnerId()inpackages/lib/src/types/owner.tsresolves one, and external iMIP organizers take the same shape asexternal_{email}(CALENDAR.md). This consistent prefix enables future load-balancer sharding by ownerId (all requests for one Home on the same server). Routes must validate that the caller has access to the specified ownerId (owns it or is a team member). Gated, carve-outs included: server-wide endpoints that don't operate on a Home (setup, server-wide admin config, public surfaces, admin backup) must NOT carry:ownerId: they're protected byrequireAdmin(user.id)or their own gate, not by Home ownership. The exempt list lives inOWNER_ID_EXEMPTinscripts/check-standards.ts - Hover-revealed affordances need the matching
pointer-coarse:variant, so they rest visible on touch (gated). See LAYOUT.md § Hover-revealed affordances rest visible on touch - Never call
getHome()for another user's data: all cross-home interactions (where one user's action touches another user's Home) must go through the relay inhome-relay.ts:sendToHome()for push,pull*()for reads.getHome()is fine for the current request's own home. Sohome-relay.tsis where sharding plugs in: only it changes when homes move to different servers. See SCALABILITY.md - Use
ColumnLayout+Columnwith thetoolbarprop for page layout: don't put the toolbar inside the page content. See LAYOUT.md § Every page is a ColumnLayout of Columns with the toolbar as a prop