Plotra Docs
Contributing

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:

  1. It checks access itself, before anything else.
  2. Its body runs inside transaction(async () => …) or readOnly(async () => …) from lib/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 readOnly when 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:

FunctionUse
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.tsFor 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

ConnectionRuns asUse for
userDb((tx) => …) from lib/db.tsThe signed-in user, under Postgres row-level securityEverything done for a user's request
db from @plotra/dbThe server itself, with no row-level securityTrusted 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.

WhereDo
Server, an expected failurethrow new Error("Message for the user."). A plain Error, written as a sentence a writer would understand
ClienterrorText(error) from @/lib/errors, usually toast.error(errorText(error)). Never read error.message
Route handlerspublicMessage(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. assertCanAddProject and assertStorage throw 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 the EventName type.

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.

On this page