Contributing
Release acceptance criteria (spec §21) and how they are verified
Each of the release's requirements, and the test that proves it.
Legend: S = cargo test -p notekpr-server (crates/server/tests/api.rs),
C = cargo test -p notekpr-core, E = browser suite tests/e2e/run.sh,
L = CLI tests cargo test -p notekpr-cli (crates/cli/tests),
X = browser ↔ CLI test tests/e2e/cross_client.py,
R = drawings' formats and editor cargo test -p notekpr-draw (unit tests;
tests/editor.rs), W = drawings in the browser, read back with the CLI
tests/e2e/drawings.py.
| # | Criterion | Verified by |
|---|---|---|
| 1 | Two independently authenticated users can create private vaults | S private_vaults_are_isolated_and_sharing_works; E test_collab (two browsers) |
| 2 | Neither user can access the other's private content | S same test: list/get/keys/changes/push on another user's vault all return 404 |
| 3 | One user can share a vault with the other | S private_vaults_are_isolated_and_sharing_works; E test_collab (Share dialog) |
| 4 | Both can collaboratively edit shared notes | S concurrent edits merge; E test_collab both editors converge, presence and cursors shown |
| 5 | Changes synchronize across connected clients | S websocket_live_sync_acks_and_presence; E test_collab; L shared_vault_reaches_running_sync_live; X browser ↔ CLI mirror both ways |
| 6 | Clients can edit offline and synchronize later | E test_features (server stopped, edits queued, restart, viewer receives them); L offline_edits_reconcile_and_pending_queue_survives_restart |
| 7 | No edits silently lost during concurrent editing or reconnection | C note_concurrent_edits_merge; S pushes_are_idempotent_and_snapshots_compact (idempotent retries); E offline test; L mirror_syncs_both_ways_and_merges_external_edits (stale external-editor save preserves both edits), concurrent_offline_creation_gives_two_files_not_a_lost_note; E test_recovered_copy, C unsent_edits_of_a_note_deleted_elsewhere_are_kept, unsent_edits_refused_because_their_note_is_gone_are_kept (unsent edits of a note someone deletes for good meanwhile are kept as a "(recovered)" copy — whether the deletion arrives live, with the catch-up after reconnecting, or as a refused upload) |
| 8 | The server cannot decrypt note contents | Architecture: the server holds no vault key (see security.md). S checks stored payloads and vault metadata contain no plaintext. Nor can it add members, plant vault keys or slip in content: L a_member_the_owner_never_added_gets_no_keys, a_vault_key_the_owner_never_made_is_refused, a_rolled_back_membership_log_is_noticed, content_a_server_slips_in_is_refused; S membership_changes_carry_owner_signed_log_entries, signed_content_is_required_once_the_log_says_so; E test_collab (signed log and changes) |
| 9 | Document bodies, attachments and sensitive filenames encrypted before upload | Paths/names live in the encrypted index object; S asserts ciphertext only; attachments test decrypts client-side |
| 10 | A revoked collaborator cannot access future content after key rotation | S viewers_are_read_only_and_revocation_rotates_keys: removed member gets 404 and their old keys cannot decrypt epoch-2 content; S removing_someone_renews_the_key_in_the_same_request (no removal without the new key; a refused key leaves everything as it was), a_signed_removal_carries_the_new_keys_entry, keys_someone_who_left_still_knows_are_marked_for_rotation (leaving, a note removed from someone's list, a shared note deleted for good and brought back), agents_are_scoped_to_shared_vaults (deleting an agent marks its keys); L revocation_rotates_keys_and_keeps_local_copy_read_only, removing_a_member_renews_every_notes_key (every note's own key moves on in the same rotation), keys_someone_who_left_knows_are_renewed_by_the_owners_sync; crates/sync a_removal_and_the_new_key_are_signed_one_after_the_other |
| 11 | Device enrollment and recovery work without server-held plaintext keys | S device_enrollment_and_recovery; E test_devices_and_recovery (code comparison, approval, recovery-key unlock) |
| 12 | The web frontend can edit encrypted vaults | E all tests |
| 13 | An authorized desktop client can read and edit Markdown locally | L mirror_syncs_both_ways_and_merges_external_edits; X files edited on disk reach the browser and vice versa |
| 14 | An authorized agent client can read and update permitted documents without exposing plaintext to the server | S agents_are_scoped_to_shared_vaults; L agent_gateway_is_scoped_audited_and_syncs, gateway_streams_change_events; agents can also use the CLI and a Markdown folder (agents.md §3) |
| 15 | Vaults can be exported in portable Markdown | Web: vault settings → Export (zip of .md + attachments), Settings → Account → Data (the vaults chosen and the quick notes in one zip, imported elsewhere: E test_export_import); L export_import_round_trip_preserves_paths_and_content, vaults_and_quick_notes_move_to_another_server (to another server and deleting the old account), without_the_server_a_device_exports_and_recovers_from_its_local_copies (offline export and recover) |
| 16 | Encrypted backups can be restored successfully | S backup_and_restore_roundtrip (online backup, restore into a new data dir, original keys decrypt notes and attachments) |
| File store | Attachments in a file store (a folder by default, S3-compatible storage, or the database): files named apart from each other, claimed by their rows, deleted a grace period after them (deleted vaults too), uploads never claimed deleted after an hour; attachments in the database moving out by themselves; a server refused with a store missing its files, or with the database store while files are elsewhere; several servers in one bucket under prefixes | S (crates/server/tests/files.rs) attachments_are_files_in_the_store_and_rows_in_the_database, files_are_deleted_a_while_after_their_rows, uploads_never_claimed_are_deleted, attachments_in_the_database_move_to_the_file_store, backup_and_restore_roundtrip (refused without the store's files); (s3.rs, task test:s3, Garage in Docker) attachments_live_in_s3_compatible_storage |
| 17 | Runs without proprietary cloud services or paid licenses | Single self-hosted binary + SQLite; any OIDC provider (verified with Dex); AGPL-3.0 |
| 18 | A new family member can start without writing code or scripts | E test_onboarding: sign in, one click to create keys, save recovery key, welcome note with instructions; toolbar for formatting |
| §6.2 | Selected notes can be shared without granting vault access; recipients get no unrelated content, metadata, attachments or keys | S individual_note_sharing (every other vault endpoint → 404, only document-key payloads, no pre-share history, live single-note subscription, stale-key refusal, viewer/editor, revoke + key rotation, purge), notes_have_own_keys_and_recipients_never_get_the_history_before_sharing (a note's own private key, then a fresh unlinked one when sharing starts: no earlier payloads or chain links reach the recipient); L single_note_sharing_end_to_end, a_new_note_is_never_under_the_vault_key_and_sharing_starts_a_fresh_key, an_older_note_moves_to_its_own_key_at_its_next_snapshot, a_note_key_the_owner_never_gave_is_refused; S note_key_grants_carry_the_owners_signature; E test_note_sharing (share dialog, recipient view, live edits both ways, presence, rename, reload, revoke) |
| §6.1 | Roles enforced by the server and shown in the client: owner, admin, editor, viewer; editors move things to the trash, only the owner and admins delete for good and read the activity log; admins are people, signed by the owner | S only_the_owner_and_admins_delete_for_good, role_dependent_writes_follow_role_changes, viewers_are_read_only_and_revocation_rotates_keys; C admins_are_signed_members_who_write_but_never_get_single_notes; L only_the_owner_and_admins_empty_the_trash; E test_vault_admins (trash and settings for an editor, then live as an admin) |
| Undoing a deletion for good | Emptying the trash (or Delete forever) can be undone by the vault's owner and admins for --purge-undo-days (default 30): gone for everyone at once (not served, writes refused, shares end), listed under Deleted for good in the trash (trash list), Undo (trash undo) puts it back in the trash and every client gets its text and comments again; a note's own key keeps moving with vault key rotations meanwhile; then the server erases it and the entry leaves the index; 0 deletes at once | S deleting_for_good_can_be_undone_for_a_while, without_an_undo_period_deleting_for_good_is_immediate; C deleted_for_good_leaves_the_trash_until_undone, an_undone_deletion_brings_the_note_back_once_its_changes_are_fetched, deletions_and_undos_in_one_page_apply_in_order; L deleting_for_good_can_be_undone; E test_undo_purge |
| Signing in the command line | Device-code sign-in (RFC 8628) approved in the browser, which shows the client's name and when and from which address the code was asked for (kept only while the code is pending, never logged) | S device_authorization_grant_for_cli, repo test single_use_login_records (the address forgotten once decided or expired); E test_device_code |
| Quick notes (Personal storage) | Each person's own quick notes, in no vault — their Personal storage, which also keeps their saved shapes: never shared (no members, agents, note shares or comments), flat, at most quick_notes_per_user (or the person's own allowance) notes, deleted for good; a note leaves by moving to a vault with its attachments, the original going only once the server has the copy; one made where there was no room stays on that device until there is; no vault may be named "Quick notes", which always means them; files only for the notes linking to them, an unlinked one going for good after an hour | S quick_notes_are_one_per_person_and_never_shared, (admin.rs) quick_notes_hold_as_many_notes_as_allowed, quick_notes_are_once_per_person_and_count_their_notes (repo); C only_ones_own_quick_notes_count, no_vault_is_named_like_the_quick_notes, files_stay_only_while_a_note_links_to_them; crates/sync a_new_quick_note_without_room_waits_with_its_key, updates_refused_for_full_quick_notes_wait_for_room; L quick_notes_need_no_vault_and_stay_flat, quick_notes_move_to_a_vault_with_their_attachments, quick_notes_hold_as_many_as_allowed, quick_notes_keep_files_only_for_their_notes, quick_notes_are_a_flat_folder_that_deletes_for_good, quick_notes_folder_takes_files_only_for_their_notes (the Markdown folder); E test_quick_notes |
| Drawings per vault | Objects say once whether they are drawings (with their first key or the update making them), never changed; each vault's drawings are counted, whoever draws (the trash counts, deleted for good doesn't), against drawings_per_vault (unlimited by default) or the vault's own allowance; a new one beyond it is refused (key: 403 limit_reached, push: drawings_full), kept on the device and sent once there's room, and a new .drawing file in the Markdown folder waits on disk; bringing one back needs room; moving a drawing into a vault without room is refused before anything is copied; administrators set the default and one vault's own, and its members' apps hear of it; the migration keeps every row | S objects_say_once_whether_they_are_drawings, new_drawings_wait_for_room_in_their_vault, (admin.rs) administrators_set_how_many_drawings_a_vault_may_hold, drawings_count_per_vault (repo), the_drawings_migration_keeps_everything (db.rs); C room_follows_the_limit, objects_are_notes_unless_said_otherwise, drawings_are_limited_per_vault; crates/sync drawings_say_so_and_wait_for_room; L a_drawing_moves_only_to_a_vault_with_room, a_new_drawing_waits_on_disk_while_its_vault_is_full; W the vault's Drawings page (its drawings from every folder, how many) |
| Drawings look as in Excalidraw | rough.js, perfect-freehand and Excalidraw's shapes ported: the same drawing gives the same SVG (but the embedded font), number for number | R same_as_excalidraws_export (example.svg, dark mode, byte for byte), same_paths_as_excalidraw_svg_export, get_stroke_matches_snapshots (perfect-freehand's own), the rough and js tests (V8's sin/cos/atan2/hypot, JavaScript's number printing) |
| Drawing files | notekpr's own .drawing files (Excalidraw's JSON); Excalidraw's .excalidraw, its clipboard and libraries, Obsidian's .excalidraw.md read and written (and its drawings under other names, by their front matter); imports turn them into drawings, embeds ![[X.excalidraw]] (or ![[X]]) find X.drawing; synced as they are (a note, a file), they show as drawings, view-only (with the pictures of the vault they link to), until made drawings when asked; the Markdown folder merges edits element by element | R round_trip_loses_nothing, clipboard_and_not_drawings, libraries_both_versions, reads_the_plugins_files, writes_what_it_reads (lz-string byte for byte); C other_apps_drawings_become_ours; L importing_other_apps_drawings_makes_drawings, writing_a_drawing_file_makes_a_drawing, drawings_are_files_merged_element_by_element, export_import_round_trip_preserves_paths_and_content, drawings_are_listed_as_drawings, vaults_and_quick_notes_move_to_another_server (the Drawings too); R pictures_shown_from_the_vault_are_named, the_front_matter_tells_a_drawing; W the Export menu, an .excalidraw file made a drawing, Obsidian's and Excalidraw's files shown view-only (embedded, opened, on the Drawings page uncounted; Obsidian's renamed one by its front matter), one made a drawing when asked, its picture with it |
| Editing drawings | Excalidraw's tools and shortcuts; selecting, moving, resizing, turning; labels (an arrow's or a line's in a gap of its line — lines too, unlike Excalidraw); arrows attached to shapes (dragged ends too, inside when held); a line's points; elbow arrows; grid and snapping; frames; sticky notes; pictures; links; copies; whatever is done, the drawing holds together; a drawing renamed from its header | R the edit tests (e.g. arrows_attach_and_follow, editing_a_lines_points, held_over_a_shape_an_arrows_end_attaches_inside, elbow_arrows_are_routed_and_follow, a_moved_box_snaps_to_another_ones_edges_and_centre, a_frame_moves_with_its_children, alt_drag_moves_copies), editing_keeps_a_drawing_whole (random sessions: unique ids, valid stacking keys, labels, attached arrows and frame members pointing at what is there and back); W tools, attached and elbow arrows, points, Alt+drag, a dropped picture, a sticky note, a frame, the grid, the palette and a link, pinching; W the labelled arrow (its line cut around the label, by its pixels) and the rename from the header (shortcuts kept out of the name); R a_line_takes_a_label_as_an_arrow_does; the graph's model (cargo test -p notekpr-web) drawings_link_with_notes_both_ways, W the drawing in the graph |
| Pictures of drawings | PNG and SVG downloads show the whole drawing, copies what is selected (with its labels, and what is in the frames among it); a frame selected on its own is downloaded and copied alone, as in Excalidraw: what overlaps it (not what is in another frame), cut to it with square corners, the picture its size without padding, no frame outlined or named, its opacity kept | R a_frame_alone_is_what_overlaps_it, what_is_selected, drawn_without_frames (export.rs), a_frame_alone (svg.rs); W a frame selected alone: the Export menu says so, copied as SVG (its size, cut to it, no outline or name) and PNG (twice its size) |
| Drawing together | Edits merge element by element and property by property (offline too); undo takes back one's own changes only; others' pointers, selections and lasers through encrypted presence; versions kept and restored | C concurrent_edits_of_one_shape_both_stay, deleting_and_editing_at_once, stacking_order_merges, undo_takes_back_own_changes_only, concurrent_drawing_edits_converge_and_keep_both (tests/properties.rs); W a second person's avatar, pointer and laser; a stored version shown and restorable |
| Pictures in drawings | A picture is a file of the vault the drawing names (stored once, encrypted), inside drawing files written out; files coming in have theirs made files of the vault (once, when the same picture is there); moving a drawing takes its pictures | C pictures_inside_become_links_and_back, a_drawings_pictures_come_along_unless_another_shows_them; L pictures_in_drawings_are_files_of_the_vault, a_drawing_moves_with_its_pictures; W a picture dropped on a drawing |
| Saved shapes | Each person's saved shapes (Excalidraw's library), in their Personal storage's encrypted index (the quick notes' vault): kept from a selection (not pictures), named after its first text, renamed, put in by a click or a drag — several picked (Shift-click ranges, Ctrl-click) go in side by side on Excalidraw's square grid, each with ids of its own —, picked ones exported or removed at once, merged between devices, no drawing slot taken; .excalidrawlib imported (once per shape) and exported, from the web and the CLI (notekpr shapes, shapes named by their ids or the start of them); read and added through the gateway (a person's own, not an agent's); the Markdown folder's Saved shapes.excalidrawlib, written when they change and an edit of it merged shape by shape against what it last held (a broken file left alone, a deleted one written again); carried by account exports as Saved shapes.excalidrawlib | C the library tests (two devices adding at once, removal, snapshots and older apps keeping it, shapes_are_renamed_and_found_by_the_start_of_their_ids, a_file_edited_elsewhere_merges_shape_by_shape), the_saved_shapes_are_a_file_beside_the_folders; R items_are_laid_out_on_a_square_grid (Excalidraw's own test), a_name_comes_from_the_first_text, several_items_go_in_side_by_side_each_with_ids_of_its_own; L crates/cli/tests/shapes.rs (import, list — also as shapes ls — and export at a limit of 0 drawings; two devices; an oversized shape left out; shapes_are_renamed_removed_and_exported_by_id), the_gateway_reads_and_adds_saved_shapes and the agent and --vault gateways answering 404 (agent.rs), saved_shapes_are_a_file_merged_shape_by_shape (mirror.rs), vaults_and_quick_notes_move_to_another_server (drawings in their vaults, saved shapes in the export); crates/web shapes_are_picked_one_by_one_or_in_ranges; W the saved-shapes steps (saved, put in by click and drag, a CLI import shown live, a dropped file, a picture refused, removed, exported; names shown and one renamed, read back by the CLI; two picked, dragged and clicked in side by side, one exported alone, Escape letting go, both removed) |
| Agents and drawings | Agents list drawings, read their words or file, and make or change one from Excalidraw's JSON | L agents_read_and_make_drawings |
| Attachments beside their notes | Files added to a note (and a drawing's pictures) go to attachments/ in its folder (the quick notes stay flat), under a name no other file of the vault has, linked by the shortest target that finds them; a note or drawing moved, or a folder moved or renamed, takes along the attachments only it uses, wherever they are — to attachments/ where it lands, from beside it to beside it; files something staying behind uses, and all of them while a user may be missing (content waiting for a key), stay — and wiki links keep finding what they found (another file of that name, a name taken there); the Markdown folder's moves take nothing along; Notion imports put each file beside the note first showing it; each file has a page: what it shows (pictures, videos, sound; never SVG or HTML), Used in (notes linking or embedding, drawings showing it), rename, move, download, trash | C attachments::tests (a_note_takes_the_files_only_it_uses, files_beside_a_note_stay_beside_it_and_its_own_attachments_follow, a_file_a_drawing_shows_goes_with_it_alone, a_folder_moved_or_renamed_takes_its_notes_files_from_elsewhere, a_name_taken_there_gets_another_and_the_links_follow, links_never_turn_to_another_file_with_the_same_name, moving_a_file_keeps_links_to_it_and_to_its_namesakes, nothing_goes_along_while_a_user_may_be_missing, …); L a_note_moves_with_the_attachments_only_it_uses, pictures_in_drawings_are_files_of_the_vault; cargo test -p notekpr-notion files_go_beside_the_notes_showing_them; E test_attachments, test_attachments_beside_notes |
| Moving between vaults | Any note or folder one can write moves to another vault one can write to (Move to vault… in the file tree's menu and a note's ⋯ menu; mv --to-vault): a folder with its notes, folders (empty ones too), files, icons and order; the attachments elsewhere its notes link to come along (to attachments/ beside it there); names taken there get a number; wiki links that wouldn't find a copy there are rewritten; the originals, and the attachments no note staying behind uses, go to the trash once the server has the copies; nothing moves while a note's text hasn't all arrived; never into the quick notes, nor files on their own | C a_folder_moves_with_everything_in_it_and_the_files_its_notes_link_to, names_taken_there_are_avoided_and_links_follow_what_came_along, a_note_moves_with_the_files_it_links_to, nothing_moves_that_is_gone_or_has_not_all_arrived; L a_folder_moves_to_another_vault_with_everything_in_it, quick_notes_move_to_a_vault_with_their_attachments (a vault's note too); E test_move_to_vault |
| Admin | Runtime instance settings: limits on accounts, agents, vaults and quick notes (per person too, with each person's own allowance), read-only mode, the instance's storage limit (every vault full at it); admin clients with scopes and their own tokens that never reach a vault | S (crates/server/tests/admin.rs) an_admin_client_manages_the_instance_but_never_reaches_a_vault (every vault, note, key, people and WebSocket route refuses its token), admin_clients_have_their_scopes_and_hand_out_no_more (scopes, delegation without escalation, rotation, revocation), ensure_keeps_a_client_as_configured, an_adminless_instance_has_no_administrators_among_its_people, limits_stop_new_accounts_vaults_and_agents, read_only_mode_keeps_everything_but_refuses_changes, a_full_database_fills_every_vault_until_there_is_room; oidc.rs a_full_server_takes_no_new_accounts_but_lets_people_back_in; L a_read_only_server_keeps_edits_until_it_accepts_them_again; E test_admin (limits, allowances and admin clients in Settings → Administration, the read-only banner, kept edits sent when it ends) |
| Storage per vault | Each vault holds at most its storage limit (its own, else the instance's default): counted from what the server stores (log, history, files; not what was deleted for good); a full vault takes no new content — edits wait in the apps and go out once it has room — while other vaults and everything but new content go on; members see its id and size in its settings; administrators see vaults' ids, owners and sizes (never names) and give one more room; quick notes have a storage limit of their own per person | S (admin.rs) quick_notes_have_a_storage_limit_of_their_own_per_person (the instance's for quick notes, not per vault; a person's own; not among the listed vaults), a_full_vault_takes_no_new_content_until_it_has_room (refusals over REST and the WebSocket, vault_full to members and to people a note is shared with, deleting for good makes room, default and per-vault limits), administrators_see_vaults_by_size_and_give_one_more_room (only id, owner and size; scopes); vault_storage_counts_what_the_vault_holds (repo: the size follows every write, prune, purge, undo and erase), full_vaults_are_counted_against_their_own_limit_or_the_default (repo: notekpr_vaults_full); C a_vault_is_full_at_its_limit; L a_full_vault_keeps_edits_until_it_has_room; E test_vault_storage (the id and size in Vault settings, the full-vault banner, edits kept and sent once an administrator gives it room; full quick notes named as such, their limit under Accounts → Limits) |
Manual checks performed
- OIDC login against a real provider (Dex in Docker): discovery, PKCE, code exchange, ID token verification, automatic provisioning, redirect back into the app. No personal data in the server logs.
- Release WebAssembly bundle: ≈3.3 MB as built by
cargo(≈740 KB with brotli), ≈2.7 MB afterwasm-opt -Oz, whichscripts/build-web.shruns when binaryen is installed — always in the Docker image. - Phone-width layout (390 px): drawer sidebar, compact header, side panel only on demand.