Zum Inhalt

Accounts

Everyone signs in with an email address and a password, or, where the server has it, through another service such as Kontext (Schreibzeug in Kontext). The accounts are Better Auth on the Schreibzeug server (apps/server/src/auth), stored in the app's own SQLite database through Better Auth's Drizzle adapter.

Server

Piece Where
Better Auth's routes: sign-up, sign-in, sign-out, session /api/auth/*, mounted in src/app.ts
Its tables user, session, account, verification src/db/auth-schema.ts, as Better Auth's Drizzle schema generator writes them
The session check in front of every other route src/auth/middleware.ts (requireUser); without a session the answer is 401 with kind: "unauthorized". A bearer token from Kontext counts as well (Schreibzeug in Kontext)
Personal access tokens, for MCP clients src/auth/tokens.ts, /api/tokens; a request with Authorization: Bearer sz_… acts for the token's user, see the MCP server

The session is a cookie, named schreibzeug.session_token (and so on, advanced.cookiePrefix): beside Kontext on one host, as localhost in development, Better Auth's default names would make each sign-in replace the other's, since cookies know no ports. Better Auth checks the Origin of requests that carry it against its trusted origins; Schreibzeug trusts its own origin, whatever host name the server is reached by, and the ones in SCHREIBZEUG_TRUSTED_ORIGINS. The desktop app needs none: its window shows the server's own pages (desktop app).

The rest of the API checks the same for requests that come with the session cookie: what changes something (every method but GET, HEAD and OPTIONS) and the WebSocket upgrades of the relay (/api/collab) and of the editor jobs (/api/agent) must come from the request's own origin, SCHREIBZEUG_URL or SCHREIBZEUG_TRUSTED_ORIGINS, else the answer is 403 (src/auth/origin.ts). The cookie is SameSite=Lax, so the browser also sends it from another origin of the same site, such as another subdomain beside Kontext or another port of the host; without the check, a page there could upload, trash or join a live session as the person. Without an Origin header the browser's Sec-Fetch-Site decides; a request with neither comes from no page. Personal access tokens and Kontext's tokens are not checked: a browser never sends them by itself.

Every node has an owner_id. Every query in src/files/repository.ts takes the signed-in user and asks src/files/access.ts what they may do with the node; a file they have no access to is "not found", the same as one that does not exist. Names are unique per owner at the top level.

The collaboration relay needs a session too: the WebSocket upgrade carries the cookie. Every connection is checked against the file, also when the file's session is already open, and the relay replaces the user that the engine announces in auth with the signed-in one, so names at cursors and on comments are real. The socket stays bound to the sign-in session it came with (src/sockets.ts): when that session ends (sign-out, a revoked session, a password change that signs out the other devices; Better Auth's databaseHooks.session.delete), or Kontext signs the person out everywhere, the relay and the jobs socket close the person's sockets (code 4401) and the engine shows that the session ended. A change to shares, the trash or a move checks every open socket against the file again (collaboration).

Sharing

The owner shares a folder or a file with the account behind an e-mail address, as edit or view (table shares; src/files/shares.ts). The person needs an account, unless the server signs in through Kontext: then a share with an address without an account waits (table pending_shares; the share dialog says "Noch ohne Konto") and becomes a share when that address gets an account the provider confirmed, at its first sign-in through Kontext or with Kontext's first token for it (claimPendingShares). A sign-up with a password never gets it, since anyone could sign up with anybody's address. Kontext uses this when a document goes into a chat, a task or an appointment (Schreibzeug in Kontext).

An address is not an identity until someone confirmed it. A password sign-up stores any address unconfirmed, also a colleague's who has no account yet, so a share by address must not silently reach whoever registered it first:

  • Where the server signs in through Kontext, a share only goes to an account whose address is confirmed. An account with a password under that address does not get it; the share waits as above ("Noch ohne Konto") until the address is confirmed, by linking the account with Kontext (sign-up). A share such an account already has keeps changing its role in place.
  • On a server with passwords only, nothing is confirmed, so a share goes to the account with the address as before, but the share list marks it (unverified in GET /api/nodes/:id/shares; the dialog says "Adresse nicht bestätigt"), so the owner sees that the account is not known to be the person.

A share on a folder holds for everything below it; the user's role on a node is "owner", or the strongest share on the node or on any folder above it, found with one recursive query (roleOf).

view edit owner
Open, download, see versions ✓ ✓ ✓
Save, restore a version, rename, create in a folder ✓ ✓
Move, trash, share ✓

Everything stays its owner's: what an editor creates in a shared folder belongs to the folder's owner, and moving only happens within the owner's own folders. A weaker role than needed answers 403 forbidden. Renaming also needs the folder the node lies in (the top level counts as its owner's): names are unique per folder, so a clash would tell someone with a share on a single node which names lie next to it. A node shared on its own keeps its name for the other person; only a change of case goes through. Trash and recent files list only the user's own nodes; "Für mich freigegeben" (GET /api/shared) lists what was shared with them, and the path of a shared folder starts at the folder that was shared, each folder in it with the role the user has on it.

Instead of an address, the owner can create a link (src/files/invites.ts, table invites): a random 24-byte token, a role and an expiry of 1, 7 or 30 days. It holds for everyone who opens it until it expires or the owner deletes it, and stops when the node goes to the trash.

The link opens /invite/<token> in the web app, which shows who shares what (GET /api/invites/:token needs no session). Someone without a session signs in or creates an account and comes back; "Einladung annehmen" adds a share with the link's role, or keeps a stronger one the user already has. A link that holds also opens a closed sign-up: the page sends its token in the x-schreibzeug-invite header, and the sign-up hook in src/auth/auth.ts lets that one account in. That account is made for the link: the hook accepts the link with the sign-up, so the person lands with the share ("Einladung annehmen" afterwards changes nothing). The address it was registered with is not confirmed by the link, which names no address; it counts as above.

In the editor the relay takes the role from the database for every connection. A viewer joins with mode: "view" whatever the client says, and the relay drops what would change the document (getLock, saveChanges, isSaveLock, the save messages; DocsCoServer refuses the first three from viewers too). The web app opens the file read-only (put_Mode("view"), asc_setViewMode) without toolbar or saving; with fast co-editing the engine is then a live viewer and shows the others' changes as they come.

Sign-up

Variable Default
SCHREIBZEUG_OPEN_SIGNUP false true: anyone who reaches the server may create an account. Otherwise only the first account can be created there.
SCHREIBZEUG_AUTH_SECRET generated Signs the session cookies, at least 32 characters. Without it the server generates one on first start and keeps it in SCHREIBZEUG_DATA_DIR/auth-secret (mode 600).
SCHREIBZEUG_TRUSTED_ORIGINS – More origins that may sign in and, with a session, change things, separated by commas.
SCHREIBZEUG_TRUSTED_PROXIES – The reverse proxies in front of the server (addresses or CIDR ranges). Sign-in and sign-up allow a few tries per client in ten seconds; a client is the connection's address, behind these proxies the first forwarded address that is not one of them, never an X-Forwarded-For a client sends itself (src/auth/client-ip.ts, installation).

The first account takes over the files from before there were accounts (nodes without an owner), so an existing installation keeps its files.

Signing in through Kontext also opens a closed sign-up: whoever Kontext knows belongs to the company (the sign-up hook lets Better Auth's callback /callback/:id in). Someone who signs in through it with the address of an existing account gets that account when its address is confirmed (Better Auth's account linking with Kontext as a trusted provider, requireLocalEmailVerified). An account with a password has an address nobody confirmed: Better Auth answers account_not_linked, the sign-in page says so, and signing in with the password links Kontext right after (linkSocial, only for the same address). When Kontext confirms the address (email_verified: true in its id token or userinfo, nothing else counts), it counts as confirmed and what was shared with it waits no longer; an address the provider does not confirm stays unconfirmed and gets nothing by it. One account has one person at Kontext: a second sub is not linked (the account hook in src/auth/auth.ts), so an address given to someone new does not hand on the former person's files. From then on the account is found by Kontext's sub, so a changed address keeps it. The variables are in Schreibzeug in Kontext.

For administrators

Schreibzeug has no administrator: no admin interface, and no account with rights over the others. The first account is an account like every other one; it only takes over the files from before there were accounts (sign-up). What that leaves to whoever runs the server:

  • No "Passwort vergessen": the sign-in page has no password reset, the server sends no e-mail, and the app has no page to change a password. Someone who forgot theirs needs the operator, who can set a new one in the container. The script hashes it the way Better Auth does (better-auth/crypto hashPassword) and writes it into the account row of the address with the provider credential (src/db/auth-schema.ts); Better Auth keeps addresses in lower case:
read -rsp "New password: " PASSWORD && export PASSWORD
docker exec -w /app/apps/server -e EMAIL=anna@example.org -e PASSWORD schreibzeug bun -e '
import { Database } from "bun:sqlite";
import { hashPassword } from "better-auth/crypto";
const { EMAIL, PASSWORD } = process.env;
if (!EMAIL || !PASSWORD || PASSWORD.length < 8) throw new Error("Needs EMAIL and a PASSWORD of at least 8 characters.");
const db = new Database("/data/schreibzeug.db");
db.exec("PRAGMA busy_timeout = 5000");
const { changes } = db
  .query("UPDATE account SET password = ?, updated_at = ? WHERE provider_id = ? AND user_id = (SELECT id FROM user WHERE email = lower(?))")
  .run(await hashPassword(PASSWORD), Date.now(), "credential", EMAIL);
console.log(changes === 1 ? "Password changed." : "No account with a password under this address.");
'
unset PASSWORD

-w /app/apps/server runs it where better-auth is installed; the password is read from the terminal, so it stays out of the shell's history. Devices that are signed in stay signed in. An account that signs in only through Kontext has no password; the script says so and changes nothing. - No deleting accounts: neither the person nor the operator can delete an account in the app. - Files are not deleted for good: the trash is never emptied, so a trashed file keeps its bytes and its versions. Versions go only by thinning; the newest version and the kept ones stay. A request to erase someone's data (GDPR Art. 17) therefore needs the operator, and there is no tool for it yet: an account's files, their versions and images, its shares, links, tokens and sessions are rows in several tables and blobs in the data folder, and a procedure that removes all of them consistently is not written yet.

Web app

src/api/account.ts wraps Better Auth's client (better-auth/client); TanStack Query keeps who is signed in. Every page but /login sits under a route that reads the session first and sends everyone without one to /login?redirect=… (src/router.tsx). A 401 from any request later does the same. The editor opens documents as the signed-in user; the navigation shows the name and the way out.

Tests

apps/server/test/accounts.test.ts: no session, no API; each person sees only their files; closed sign-up and the takeover of old files; the secret file. The server tests sign up an account in their harness. In the E2E run (SCHREIBZEUG_OPEN_SIGNUP=true) every test signs up an account of its own through the API (e2e/app.ts); e2e/accounts.spec.ts goes through the form, signs out and back in, and checks that someone else's folder is not found. apps/server/test/shares.test.ts covers the roles, shares on folders, taking a share back, the trash and the mark on an unconfirmed address; apps/server/test/edge.test.ts the body limits, the address the sign-in rate limit counts, and the origin check for cookie requests and WebSocket upgrades; e2e/sharing.spec.ts shares a folder between two accounts: read-only with live changes, and editing; and someone without an account follows a link, signs up and accepts it. apps/server/test/invites.test.ts covers links: reading one without a session, accepting, roles that never get weaker, deleting, expiry, the trash, and a closed sign-up opened only by a link that holds, which accepts that link.