An issue tracker for teams in the style of Linear, written entirely in x86-64 assembly for Linux. No libc, no libraries, only raw syscalls. Every layer is hand-written: the WebSocket server, the Noise encryption and the crypto under it, the journaled database, the sync engine, the Wayland client, the rasterizer and the anti-aliased font.
todo-serverholds accounts, teams, their issues and their messages, and pushes every change to everyone viewing the same team.todo-clientis a native Wayland window. It speaks the Wayland wire protocol over the compositor socket, draws every pixel into shared memory, and keeps one encrypted connection to the server.
Needs nasm, ld and make.
make # builds build/todo-server and build/todo-client
make test # crypto test vectors and UI checks
make preview # renders every text style and icon to build/preview.ppm
make lint # function length, file length and function comments
make run # starts the server with ./issues.db, opens a window, stops the server on close
Or run them separately:
build/todo-server [database-path] [ip:port] # defaults: ./issues.db, 127.0.0.1:8080
build/todo-client [<server-key> [ip:port]]
CONTRIBUTING.md has the rules for changes. CI runs the build, the tests and the lint on every pull request.
The server prints its public key at startup and writes it to <database-path>.pub. The client needs that key and refuses any server that can't prove it holds the matching private key. It remembers the last server and your login in $XDG_STATE_HOME/asm-todo/session (or ~/.local/state/asm-todo/session), so after the first run a bare todo-client reconnects and skips the login. Logging out forgets the login but keeps the server.
Sign up or log in, then create a team. Its identifier (like ENG) prefixes issue IDs like ENG-12.
Your name at the top of the sidebar, or "Account settings" in the command menu, opens your account. Profile has your user number, when you joined and how many teams you're on, and Delete account, which needs your password and asks first. You can't delete your account while you're the last owner of a team. Deleting it takes you off every team and deletes your direct messages. Issues, comments and channel messages you wrote stay, and the audit log calls you "Deleted user". Password and sessions ("Change password" in the command menu) changes your password, which logs out your other sessions, and lists your sessions with when each signed in and last connected, Log out on every other one, and Log out everywhere else. Teams ("All teams" in the command menu) lists every team you're on with your role, its members and open issues, and links to open it, open its settings or leave it. It has New team too.
The gear that shows when you hover over the team's sidebar row opens its settings, on the Overview tab: how many members there are and how many have the team open, open and done issues, issues by status, who holds each role, and the newest audit log entries for those who can see the log. Owners also get Delete team there, which needs the team's key typed, asks first, and deletes its issues, messages, channels and images. A team has owners and members, and members get permissions from roles. Everyone sees the member list, with a search box that matches names and nicknames, and can leave, except the last owner. What else shows depends on the permissions you have: renaming the team, inviting people by username, the Roles tab, the ban list with Unban, the audit log and the AutoMod tab. The audit log has the newest 50 invites, removals, bans, unbans, timeouts, role and nickname changes, channel and AutoMod settings, messages AutoMod blocked, ownership transfers, renames and deletions of other people's posts, with who did it, when and why.
The Roles tab lists the team's roles, highest first, and @everyone, which every member has. A team can have 16 roles, each with a name, a color and a switch for each of 15 permissions: Administrator, rename the team, manage roles, manage channels, invite, kick, ban, time out, delete others' messages, view the audit log, change others' nicknames, change your own, manage AutoMod, view channels and send messages. Administrator gives every permission and skips channel overrides. New teams start with a Moderator role that can invite, kick, ban, time out, delete messages, view the audit log and change nicknames, and @everyone can view channels, send messages and change their own nickname. A member has @everyone's permissions plus those of all their roles, and owners have them all. Their rank is their highest role's place in the list. Managing roles only reaches roles below your own highest one, and only hands out permissions you have. Move up and Move down reorder them.
"Manage" on a member opens their page, and on yourself lets you change your own nickname. Depending on your permissions and as long as they rank below you, you can set their nickname of up to 32 characters, switch their roles on and off, make them an owner or hand them the team with Transfer ownership, which makes you a member (owners only), time them out for 60 seconds, 5 or 10 minutes, an hour, a day or a week, end a timeout early, kick them, or ban them, which also keeps them from being invited back until they're unbanned. A ban can also delete their messages in the team from the last hour, day or week. A reason typed on the page goes in the audit log. Owners can't be timed out, and neither can administrators. A timed out member can still read everything and leave, but can't post, comment, change issues or make channels until it ends. Remove, Leave, Kick, Ban, Transfer ownership and Delete role ask first. The "+" next to Direct messages opens the same dialog at the invite box, for anyone who can invite.
The AutoMod tab holds the team's blocked words, one per line, up to 1,000 characters, and a timeout for anyone who uses one. A word matches whole and ignoring case, and a * at either end lets it run on into the rest of a word, so spam* catches "spammer". A message that matches, posted or edited in a channel, isn't stored and goes in the audit log. Members who can manage AutoMod aren't checked.
Issues have a status (Backlog, Todo, In Progress, Done, Canceled), a priority, an assignee, a project, up to 16 labels, a description and comments. The list groups them by status, and the board shows a column per status. The sidebar switches between all issues, active ones, the backlog, your own issues and a single project. The filter bar narrows any of these by label, project, priority or assignee.
Drag a card on the board to another column to change its status. Esc while dragging puts it back.
Clicking an issue opens it, and right-clicking one opens a menu of what you can do to it. Click its title or description to edit them, and use the properties on the right to change the rest. Comments show up live for everyone viewing the issue. Hover over your own comment to edit or delete it. Members who can delete others' messages can delete anyone's.
Each team has a #general channel, and anyone in it can make up to 16 more with the "+" next to Channels. "Edit channel" in a channel's header renames or deletes it, for whoever made it and for anyone who can manage channels. The same dialog sets its slowmode, from 5 seconds to an hour between messages per member, and its overrides: for @everyone and each role, whether viewing, sending and deleting others' messages there is allowed, denied, or left to the role's permissions. @everyone's override applies first, then the member's roles' together, where an allow beats a deny. A channel you can't view doesn't show, and in one you can't send in the message box says so. Members who can delete others' messages there aren't held by slowmode. Deleting one deletes its messages. Clicking someone under Direct messages opens a conversation with them. Messages arrive live, and a dot marks conversations with messages you haven't seen yet, even after a restart. Opening a conversation loads its newest 100 messages. Messages can be up to 500 characters, and each server keeps the newest 32,768 across all teams.
Hover over your own message to edit or delete it. Members who can delete others' messages can delete anyone's in a channel, but not in someone else's direct conversation. Edited messages say so.
Type @ in a message, comment or description to mention someone on the team. The members whose names start with what follows come up by the caret. The arrows move through them, Enter, Tab or a click puts the name in, and Esc closes the list. Mentions of members show in color, and yours on a highlight too. A channel with a message you haven't seen that mentions you gets an @ instead of the dot, even after a restart. Mentions in comments and descriptions are only highlighted.
Paste an image into the message or comment box with Ctrl+V, drop one on it, or pick one with the image button beside the send button. A screenshot, an image copied from a browser, and a PNG file from a file manager all work. The button opens zenity, kdialog or yad, whichever is installed. It uploads while you type, shows above the box with an X to remove it, and goes out with the text on Enter or the send button, or on its own. Click an image to see it as big as the window allows, and click or press Esc to close it. Images are PNGs up to 8 MB and 16 megapixels. Deleting the message or comment deletes its image.
Keys, when no text field has focus:
| Key | Does |
|---|---|
Ctrl+K |
command menu: every action, view and team, and issues by title or ID, filtered as you type |
C |
new issue |
J / K, arrows |
move the selection. Left and right change board columns |
Enter |
open the selected issue |
S P A L M |
status, priority, assignee, labels, project |
Ctrl+B |
switch between list and board |
Ctrl+C |
copy the selected issue's ID |
Esc |
close the issue, conversation, menu or dialog |
In menus, typing filters and Enter picks. In the label and project menus, typing a new name offers to create it. "Manage labels and projects" in the command menu renames and deletes them. Deleting one takes it off every issue. In the new issue dialog, Tab moves between title and description and Ctrl+Enter creates the issue. In comments and messages, Enter sends and Shift+Enter adds a line. In an open conversation, typing anywhere goes to the message box.
The keyboard layout comes from the compositor, so AZERTY, QWERTZ, Dvorak and others type what their keys say, and letter shortcuts follow the letter rather than the key's position. Characters outside ASCII can't be typed yet.
Text fields work like any other app's: Shift with the arrows, Home or End selects, Ctrl moves by word, double-click selects a word and triple-click everything, and dragging selects. Ctrl+A, Ctrl+C, Ctrl+X and Ctrl+V select all, copy, cut and paste through the system clipboard. Ctrl+Backspace and Ctrl+Delete remove a word. Ctrl+Z undoes, a word at a time while typing, and Ctrl+Y or Ctrl+Shift+Z redoes. Password fields keep no history. Pasted text keeps to printable ASCII: curly quotes, dashes and the like become their plain versions.
Dates are shown in the local time zone, from TZ or /etc/localtime.
Changes and messages made while the server is unreachable show up right away and are sent once the client reconnects.
- Transport: WebSocket carrying
Noise_NK_25519_ChaChaPoly_SHA256. The client pins the server's static key, every connection gets fresh ephemeral keys, and any forged, replayed or reordered message closes the connection. - Passwords: PBKDF2-HMAC-SHA256 with 600,000 iterations and a random 16-byte salt per user. Hashing runs in slices between events, so logins never stall the server. Logins with an unknown username are hashed against a dummy salt, so response time doesn't reveal which usernames exist.
- Sessions: 256-bit random tokens, stored only as SHA-256 hashes, valid for 30 days, compared in constant time. Logging out revokes the session on every connection using it. Changing a password needs the current one, hashes the new one with a fresh salt, and revokes every other session of the user. It counts against the same per-IP limit as logins. A user sees their sessions that haven't expired and can revoke any other one, or all of them. Deleting an account needs its password, counts against the same limit, is refused while the user is the last owner of a team, and revokes every session.
- Limits: 10 password checks per IP per minute, 5 failed attempts per connection, 16 connections per IP, 30 seconds to log in, 90 seconds idle. Clients that don't read their messages get disconnected instead of buffered without bound.
- Authorization: every operation checks team membership and the member's permissions on the server, and every field is validated there. Only owners make owners, transfer ownership or delete a team, and a team always keeps one. Acting on a member or a role needs to rank above them, and a role can't be given permissions its editor doesn't have. The server refuses anyone banned from a team as a member of it. Timed out members are refused every change until the timeout ends. Removed and banned members stop receiving updates and messages immediately and are unassigned from the team's issues. The ban list, the audit log and the AutoMod rules only go to members with the permission for them. A channel's messages, unread marks and images only go to members who can view it, and its slowmode and AutoMod are checked on the server. Direct messages only reach the two people in them.
- Storage: the database and its journal are created with mode 0600. The database holds the server's private key. The client's session file is mode 0600 in a 0700 directory, and is replaced by rename so a crash leaves the old one or the new one.
The database is a snapshot plus a journal next to it (issues.db.log). Every change is appended to the journal as a checksummed batch and fdatasynced before anyone hears about it. On startup the server replays the journal over the snapshot, drops a torn last batch, then writes a fresh snapshot (temp file, fsync, rename, directory fsync) and starts a new journal. It also compacts once the journal passes 8 MB. A generation number in both files keeps a journal from being replayed over the wrong snapshot. Snapshots carry a format version: version 2 added a comment's edited mark, version 3 added channels and a message's edited mark, version 4 added images, version 5 added a member's timeout and roles, bans, the audit log, roles, channel settings, nicknames and AutoMod rules, and version 6 added when an account was made and when a session last connected. The server still loads versions 1 to 5 and saves them as 6. Accounts from before version 6 have no join date. The audit log keeps the newest 16,384 entries across all teams.
Images are files in a directory beside the database (issues.db.images/), one per image, named by its id and created with mode 0600. The database has a row for each, written when its upload starts. A file only counts once the row says every byte arrived, and the last part is fdatasynced first. Uploads the server stopped in the middle of are deleted on startup.
After GET /sync upgrades to WebSocket, the client sends Noise message 1 and the server replies with message 2. Every binary frame after that is one encrypted message whose first byte is its type. src/common/config.inc lists every message and field.
Sync works like this:
- Each team has a sequence number that goes up with every change.
SUBSCRIBEreturns the team's labels and projects, then a snapshot of its issues at the current sequence. Every change after that arrives as anEVENTwith the next number. A client that sees a gap asks for a fresh snapshot. - Descriptions and comments aren't in the snapshot.
OPEN_ISSUEfetches them for one issue, and events keep the open issue current. - Comments are changed with
EDIT_COMMENT(its author only) andDELETE_COMMENT(its author or a member who can delete others' messages), which reach viewers of the issue asCOMMENT_EDITEDandCOMMENT_DELETEDevents. A comment carries an edited mark once changed. - Messages are sent as changes too, but they aren't events and don't move the sequence number.
SUBSCRIBEalso returnsCHANNELSandUNREAD, the conversations with messages the user hasn't seen and the channels among them where one of those mentions the user.OPEN_CHATfetches a conversation's newest messages and marks them seen, andCHATdelivers each new message to the connections allowed to read it. A conversation is named by its peer: 0 for#general,CHANNEL_PEERplus a slot for another channel, or the other person. REMOVE_MEMBER,BANandTIMEOUTcarry a reason,BANalso how many seconds of the member's messages to delete, andUNBANtakes a ban back.MEMBERSsays who's timed out until when, which roles each member has, and their nickname.SUBSCRIBEreturnsROLES, and members with the permission for them getBANS,AUDITandAUTOMODtoo, all again after every change to them. A change refused because its sender is timed out is acknowledged withACK_TIMED_OUT, one held back by slowmode withACK_SLOWMODE, and one AutoMod blocked withACK_BLOCKED.SAVE_ROLEmakes, changes or deletes a role, or changes@everyone's permissions,MOVE_ROLEmoves one up or down, andMEMBER_ROLESsets which roles a member has. Everyone viewing the team gets the newROLES,MEMBERSandCHANNELSafter any of them.NICKNAMEsets or clears a member's nickname, andSET_AUTOMODthe blocked words and their timeout.SET_CHANNELmakes, renames or deletes a channel, andCHANNEL_SETTINGSsets a channel's slowmode and overrides. Everyone viewing the team gets their ownCHANNELS: the channels they can view,#generalamong them as slot 0, each with what they can do in it.EDIT_MESSAGEandDELETE_MESSAGEchange a message, and its readers getCHAT_EDITEDorCHAT_DELETED. These,SET_ROLE,RENAME_TEAM, the role and channel settings messages and the moderation messages aren't changes in the op queue, so they need a connection.- An image is uploaded before the message or comment it goes with:
UPLOAD_IMAGEgives its size and the conversation it's for, and the server answers eachIMAGE_PARTwithUPLOAD_READYand the bytes it has, so one part is in flight at a time. The server checks the file starts as a PNG of the size it said. The message or comment then names the image's id, and only its uploader can attach it, once, in that conversation.GET_IMAGEdownloads one a part at a time, for whoever can read what it's attached to. Messages and comments carry their image's id and size, so clients lay them out before downloading them. ACCOUNT_INFOasks forACCOUNT: when the user joined, their sessions that haven't expired with when each signed in and last connected, and each of their teams' member count and open issues.END_SESSIONlogs out another session, or all of them.DELETE_ACCOUNTtakes the password and is answered withLOGGED_OUT.DELETE_TEAMtakes the team's key, and its members get a newTEAMS.TRANSFER_OWNERmakes a member the owner and the sender a member. These aren't changes in the op queue either.- Clients number their changes 1, 2, 3 per session. The server applies them in order and remembers the last one per session, so a change resent after a dropped connection is acknowledged without being applied twice.
- The client keeps the server's state and its own unacknowledged changes apart, and shows the two merged. That's how offline edits appear immediately and how a rejected change disappears cleanly.
src/common/crypto/has SHA-256, SHA-1, HMAC, HKDF, PBKDF2, ChaCha20-Poly1305 and X25519.src/common/noise.asmhas the Noise handshake and transport.src/common/ws.asmandsrc/common/wire.asmhave WebSocket framing and the message reader and writers, andsrc/common/mention.asmwhat counts as a mention, so both sides agree.src/server/db.asmhas the tables, the journal and snapshots.src/server/conn.asmhas the event loop's connections: HTTP upgrade, handshake, framing and buffered writes.src/server/handlers.asmhas accounts and sessions, andpassword.asmchanging a password.teams.asmhas teams, members and owners,roles.asmroles, permissions and ranks,nicknames.asmnicknames,moderation.asmbans, timeouts and the audit log, andautomod.asmthe blocked words and checking messages against them,sessions.asmthe account's sessions and its teams' sizes, andteardown.asmdeleting teams and accounts.issues.asmhas snapshots, events and every change to issues, labels and projects.messages.asmhas messages, editing them and seen marks,channels.asmthe channels besides#generaland every channel's slowmode, overrides and who can see it, andimages.asmuploading, storing and serving images.src/client/net.asmhas the connection state machine, keepalive and reconnect.src/client/sync.asmhas the client's copy of the team, the pending changes, and the merged view.src/client/wayland.asmhas the window.draw.asm,text.asm,shapes.asm,font.asm,icons.asmandglyphs.asmhave the rasterizer, the anti-aliased stroke font, the anti-aliased shapes, the status, priority and avatar icons, and the line icons.src/client/ui.asmhas the client's state and list logic,input.asmthe mouse and keyboard,keymap.asmthe keyboard layout,edit.asmtext field editing and selection,clipboard.asmcopy, paste and drops,widgets.asmfields, buttons, switches and chips,menu.asmthe command menu and the pickers,views.asmthe screens,chips.asmthe label and project chips on rows and cards,sidebar.asmthe sidebar,chat.asmopening and drawing a conversation,comments.asman issue's comments and editing them, and the Edit and Delete links messages share,labels.asmthe dialog for renaming and deleting labels and projects,account.asmandaccountpage.asmthe account dialog with its profile, password and sessions, and teams,dialogs.asmopening and submitting the new issue and new team dialogs,members.asmthe team settings dialog and its member list,overview.asmits Overview tab,dashboard.asmits member pages and ban list,audit.asmthe audit log,access.asmthe team's roles and what they let us do,rolepage.asmthe Roles and AutoMod tabs,channel.asmthe channel list and its dialog with the slowmode and overrides,chatedit.asmediting and deleting messages,undo.asmundo and redo in text fields,attach.asmpasting, dropping and uploading an image,pick.asmthe box buttons and the file dialog behind the image one,thumbs.asmdownloading, caching and drawing images, andmentions.asmthe list of members while a mention is typed, drawing mentions and the channels'@marks.inflate.asmandpng.asmdecode PNGs, scaled down as they're decoded.src/client/session.asmremembers the server and login between runs, andlocaltime.asmreads the time zone.tests/crypto_test.asmchecks the crypto against published test vectors.tests/ui_test.asmdraws the client into memory and checks how it answers clicks, keys and the wheel, with the checks of messages intests/ui_messages.asm, of the newer controls intests/ui_controls.asm, of mentions intests/ui_mentions.asm, of moderation intests/ui_moderation.asm, of roles, nicknames, channel settings and AutoMod intests/ui_roles.asm, and of the account dialog and the Overview intests/ui_account.asm.tests/png_test.asmdecodes the PNGstests/images/make_images.pywrote, one for every color type, filter and kind of deflate block, and compares the pixels.tests/font_preview.asmrenders the font and icons for a look.