How the app works
Server actions, the access check, errors, and the two database connections in apps/app.
Four things to understand before you change anything under apps/app/lib. Each one exists because getting it wrong would expose or lose someone's writing.
Server actions are public endpoints
Files named lib/*-actions.ts begin with "use server". Next.js turns every function exported from such a file into an endpoint that anyone who can reach the app can call, with any arguments they like. The interface calling it politely is not a guarantee.
So every exported function in an actions file does two things:
- It checks access itself, before anything else.
- Its body runs inside
transaction(async () => …)orreadOnly(async () => …)fromlib/request-cache.ts.
"use server";
export async function renameThing(projectId: string, title: string): Promise<void> {
return transaction(async () => {
await requireProjectAccess(projectId, "edit");
const clean = title.trim();
if (!clean) throw new Error("Give it a title.");
await userDb((tx) => tx.update(thing).set({ title: clean }).where(eq(thing.projectId, projectId)));
});
}What the wrapper gives you:
- One database transaction for the whole action, committed when it returns and rolled back when it throws. Use
readOnlywhen the action only reads. - Lookups that repeat within one action (the session, the access check) are made once.
- A deliberately thrown error keeps its message on the way to the browser. See Errors.
A helper that is not meant to be an endpoint must not live in an -actions.ts file. Put it in a plain module and import it.
Reads the client polls
Server actions queue: a second one waits for the first. That is wrong for things the interface asks for repeatedly, such as presence or a hover preview, because a poll would hold up an edit. Those go through lib/reads.ts, and the client calls them with read("name", …) from lib/read.ts. The same rules apply: each read checks access itself.
Access is one check
Access is set per workspace and per project, like sharing a document. There are no permissions on single notes.
- A workspace member can read, comment, edit and delete in every project of the workspace. Owners and admins can also manage it.
- A project can be shared with anyone by email as viewer (read), commenter (read, comment) or editor (read, comment, edit, delete).
- Someone who is both gets whichever lets them do more.
The rules are pure functions in lib/access/policy.ts. Read that file first; it is short. The functions that apply them are in lib/access/index.ts:
| Function | Use |
|---|---|
can(action, projectId) | Returns true or false |
requireProjectAccess(projectId, action) | Returns the project, or throws. The usual first line of an action |
requireNodeAccess(projectId, nodeId, action) | The same for one note. Also checks that the note is in that project |
getProjectAccess(projectId) | The project and the full set of actions, when you need more than one answer |
requireAdmin(workspaceSlug) in lib/workspace.ts | For workspace settings |
The actions are read, comment, edit, delete and manage.
Use these. Don't write a second way of deciding who may do something. The collab server doesn't decide either: the Next app issues it a short-lived token that carries the role, and viewers and commenters get read-only connections.
Two database connections
| Connection | Runs as | Use for |
|---|---|---|
userDb((tx) => …) from lib/db.ts | The signed-in user, under Postgres row-level security | Everything done for a user's request |
db from @plotra/db | The server itself, with no row-level security | Trusted system work only |
userDb is the default. Row-level security means the database itself refuses rows the user has no access to. If an access check in an action had a bug, this is the second wall.
db is for work that isn't on behalf of one user's permissions: Better Auth, the collab server's callbacks, quotas, rate limits, usage events. Some tables can only be written this way (workspace_settings, event, throttle). If you use db inside a user request, do your own access check first, and leave a comment that says why.
The database
The schema is in packages/db/src/schema, the migrations in packages/db/drizzle.
Don't add or edit a migration, or change a table definition, without agreeing it in an issue first. Three reasons:
- Migrations run against a database that holds people's manuscripts. A bad one is the kind of mistake a writing tool doesn't recover from.
- Migration files are numbered. Two pull requests that both add the next number collide, and the fix is not a simple merge.
- Every new table needs row-level security policies, and they are easy to get subtly wrong.
Never edit a migration that is already on master. Add a new one.
Errors
In a production build, Next.js replaces the message of anything thrown on the server with a generic one, so that details can't leak. That would also turn "Give the project a title." into "Something went wrong." lib/errors.ts lets the deliberate messages through.
| Where | Do |
|---|---|
| Server, an expected failure | throw new Error("Message for the user."). A plain Error, written as a sentence a writer would understand |
| Client | errorText(error) from @/lib/errors, usually toast.error(errorText(error)). Never read error.message |
| Route handlers | publicMessage(error) |
Only plain Errors thrown inside transaction or readOnly are passed through. Database errors, network errors and bugs are other classes, so their details stay on the server and the user sees the generic message.
Quotas, rate limits and usage events
Three small modules protect the hosted version. Which limits apply to a workspace depends on its plan, Free or Pro; the plan and everything about payment live in lib/billing/, which does nothing unless CREEM_API_KEY is set. No feature is gated by plan: Pro only raises limits and adds hosted AI.
lib/quotas.ts: projects per workspace, storage per workspace, file size.assertCanAddProjectandassertStoragethrow a message that names the limit and says that a self-hosted Plotra has none. A quota only stops something being added. It never deletes, locks or blocks an export. Each has an environment override.lib/rate-limit.ts:rateLimit(what, who, { limit, windowSeconds })for anything that sends email or costs server time.lib/analytics.ts:track(name, …)records that something happened. Names and ids only, never titles or text. Add new names to theEventNametype.
Optional services stay optional
Email (Resend), file storage (R2), Google sign-in, PostHog and error tracking are all optional. With their variables unset the app must still work: emails are printed to the terminal, files go to Postgres, the Google button is hidden. A new integration has to behave the same way, and its variables go in apps/app/.env.example with a comment.
The AI agent
The agent runs on the user's own API key or a local model. The project never pays for a model call, and there must be no code path where it does. The agent's tools are thin wrappers over the same server actions the interface uses, so they pass through the same access check as the person who started the run.