notekprdocs

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.

#CriterionVerified by
1Two independently authenticated users can create private vaultsS private_vaults_are_isolated_and_sharing_works; E test_collab (two browsers)
2Neither user can access the other's private contentS same test: list/get/keys/changes/push on another user's vault all return 404
3One user can share a vault with the otherS private_vaults_are_isolated_and_sharing_works; E test_collab (Share dialog)
4Both can collaboratively edit shared notesS concurrent edits merge; E test_collab both editors converge, presence and cursors shown
5Changes synchronize across connected clientsS websocket_live_sync_acks_and_presence; E test_collab; L shared_vault_reaches_running_sync_live; X browser ↔ CLI mirror both ways
6Clients can edit offline and synchronize laterE test_features (server stopped, edits queued, restart, viewer receives them); L offline_edits_reconcile_and_pending_queue_survives_restart
7No edits silently lost during concurrent editing or reconnectionC 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)
8The server cannot decrypt note contentsArchitecture: 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)
9Document bodies, attachments and sensitive filenames encrypted before uploadPaths/names live in the encrypted index object; S asserts ciphertext only; attachments test decrypts client-side
10A revoked collaborator cannot access future content after key rotationS 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
11Device enrollment and recovery work without server-held plaintext keysS device_enrollment_and_recovery; E test_devices_and_recovery (code comparison, approval, recovery-key unlock)
12The web frontend can edit encrypted vaultsE all tests
13An authorized desktop client can read and edit Markdown locallyL mirror_syncs_both_ways_and_merges_external_edits; X files edited on disk reach the browser and vice versa
14An authorized agent client can read and update permitted documents without exposing plaintext to the serverS 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)
15Vaults can be exported in portable MarkdownWeb: 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)
16Encrypted backups can be restored successfullyS backup_and_restore_roundtrip (online backup, restore into a new data dir, original keys decrypt notes and attachments)
File storeAttachments 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 prefixesS (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
17Runs without proprietary cloud services or paid licensesSingle self-hosted binary + SQLite; any OIDC provider (verified with Dex); AGPL-3.0
18A new family member can start without writing code or scriptsE test_onboarding: sign in, one click to create keys, save recovery key, welcome note with instructions; toolbar for formatting
§6.2Selected notes can be shared without granting vault access; recipients get no unrelated content, metadata, attachments or keysS 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.1Roles 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 ownerS 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 goodEmptying 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 onceS 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 lineDevice-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 hourS 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 vaultObjects 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 rowS 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 Excalidrawrough.js, perfect-freehand and Excalidraw's shapes ported: the same drawing gives the same SVG (but the embedded font), number for numberR 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 filesnotekpr'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 elementR 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 drawingsExcalidraw'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 headerR 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 drawingsPNG 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 keptR 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 togetherEdits 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 restoredC 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 drawingsA 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 picturesC 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 shapesEach 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.excalidrawlibC 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 drawingsAgents list drawings, read their words or file, and make or change one from Excalidraw's JSONL agents_read_and_make_drawings
Attachments beside their notesFiles 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, trashC 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 vaultsAny 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 ownC 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
AdminRuntime 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 vaultS (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 vaultEach 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 personS (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 after wasm-opt -Oz, which scripts/build-web.sh runs when binaryen is installed — always in the Docker image.
  • Phone-width layout (390 px): drawer sidebar, compact header, side panel only on demand.