Permissions
Every action a person can take on an entry, and the scope it is allowed in. The rule underneath all of it:
An entry belongs to whoever wrote it. Everyone else has a copy. You can do anything to your copy and nothing to theirs.
Owns: permission. Who may act on an entry or inside a space, what happens when they
try anyway, and the path:line where each rule is enforced. Any new action on a card menu,
a bulk bar, or a space route gets a row here.
Not here: how the action works. Payloads and routes are the backend’s
(orange-copy-paste-clipboard-backend/docs/architecture.md), client mechanics are the
app’s (orange-copy-paste-clipboard-app-rust/docs/architecture.md). This file answers
may they, not how.
Server rows are keyed (user_id, client_id), so this is not only policy - a
write against someone else’s entry cannot reach their row. It would insert a
second row under the same client_id and hand every member a rival copy. That
is why the blocks below exist: not to be strict, but because the alternative is
silent duplication.
Keep this table current when you add an action. If a new control is not in it, its scope has not been decided.
1. Entries
Section titled “1. Entries”“Yours” means you wrote it. “Theirs” means it arrived from another member of a
space (id_map.is_remote, surfaced to the UI as useRemoteEntryKeys).
| Action | Where | Yours | Theirs | Enforced by |
|---|---|---|---|---|
| Copy | card menu, Spaces feed | yes | yes | - |
| Expand / open | card, editor | yes | yes | - |
| Pin | card menu, editor | yes | yes, local only | push guard |
| Save | card menu | yes | yes, local only | push guard |
| Assign groups | card menu, editor | yes | yes, local only | push guard |
| Edit note title / body | note editor | yes | no | read-only editor + update_note |
| Delete locally | card menu, bulk bar | yes | yes, local only | spawn_delete_entry skips the tombstone |
| Upload to cloud | card menu, bulk bar | yes | hidden | sync_push_entries skips |
| Remove from cloud | card menu, bulk bar | yes | hidden | sync_unpush_entries skips |
| Remove everything from cloud | Account screen | yes | never reached | sync_unpush_all builds from owned_keys; spawn_delete_entry refuses OwnedOnly |
| Share to a space | card menu, bulk bar | yes | hidden | push guard |
| Remove from a space | Spaces feed, bulk bar | yes (unshare) | owner only | backend remove_entry_from_space |
| Comment on it | Spaces detail panel | yes | yes | backend _require_member |
| Delete a comment | Spaces detail panel | yes (your own) | owner only | backend delete_comment |
Local only means the change lives on this device and is never pushed. Your groups and pins are your filing system; they are not part of the entry as its author published it.
Hidden means the row is not rendered at all, rather than shown disabled. A control that cannot work should not be offered - the version of this that showed it anyway reported “Removing 1 item from your account” and removed nothing.
Never reached is for account-wide actions, which have no per-item control to hide. They are the dangerous shape: nobody picked the items, so nothing on screen shows what is about to be touched, and a list built one field too wide takes things away silently. Two rules, both required:
- Build the list from
IdMap::owned_keys()- neverentry_states(), which records what this device pushed and what it pulled, so it includes items other members shared in. - Pass
RemovalScope::OwnedOnly, sospawn_delete_entryrefuses a received item even if the list is wrong. Rule 1 is the intent; rule 2 is what makes getting it wrong inert instead of destructive.
A removal must also survive the round trip. “Remove from cloud” keeps the local
copy, but on the wire a removal is a tombstone: a push with deleted_at set, the
same message a real deletion sends. It comes back on the next pull as “this entry
is deleted”, and applying it would wipe the copy the action promised to keep. The
device records what it meant (IdMap::mark_unpushed), and merge_pulled skips
the local delete for those keys. Echo suppression by device_id cannot do this
job. A row records the device that pushed it, and an entry pushed under an
earlier sign-in comes back with an id this install no longer has.
This is written down because it has already gone wrong once: “Remove from
cloud” on the Account screen swept entry_states(), so it dropped the user’s
copies of items other people had shared with them. The authors kept theirs - the
removal stayed local, as spawn_delete_entry intends for someone else’s item -
so the damage was one-sided and invisible from the other end.
2. Spaces
Section titled “2. Spaces”| Action | Space owner | Member |
|---|---|---|
| Rename, delete the space | yes | no |
| Invite by email | yes | no |
| Accept an email invite | n/a | only with a verified address (email_verified not false on the token) |
| Mint the space’s key | yes | no |
| Hand the key to a member who lacks one | yes | yes (any keyholder), only onto a member with no key |
| Replace a key a member already holds | yes | no |
| Change the share-history policy | yes | no |
| Choose whether members may approve join requests | yes | no |
| Approve or decline a join request | yes | only if the owner turned it on |
| Remove a member | yes | no |
| Leave | n/a | yes |
| Take down any entry in the space | yes | no |
| Take down their own entry | yes | yes |
| Comment on any entry in the space | yes | yes |
| Delete their own comment | yes | yes |
| Delete anyone’s comment | yes | no |
| Set per-space send filters | yes | yes (own client, own choice) |
| Share an entry into the space | yes | yes |
Reading a space needs its Space Key. Content is encrypted under it, so until a copy arrives there is nothing to decrypt. This is a capability rather than a role: it applies to an owner too, in the window between creating a space and minting its key.
Two things keep that window short:
- Any member holding the key hands it over, not only the owner, so somebody is nearly always running.
- An invite carries the key wrapped for the invitee, so an invited member holds it the moment they accept.
Space.has_key carries the state to the UI. It is stamped from the live keyring on every
read, since the key can land at any moment.
Sharing into a space is never blocked on the key. The choice is recorded right away.
The push path drops any space it holds no key for, so nothing unreadable is ever pushed,
and the entry is pushed again when the key lands. The record in id_map.json is the queue, so it survives a
restart.
| Attempted while waiting for the key | What happens |
|---|---|
| Share an entry in (card menu, bulk bar) | allowed; the row says “waiting” and the entry’s chip is dimmed until it goes out |
| Turn on “Share new items out” | allowed; matching items are held and sent when the key arrives |
| Comment on an entry | command refuses; nothing is readable to comment on anyway |
| Read the feed | empty - the entries cannot be decrypted yet |
Who gets told. Five notification rows come out of this. All five follow the same ownership rule as the actions above:
- a space becoming readable, because you are in it
- a comment on an entry you wrote, or one that names you
- a refused keyring
- somebody knocking on a space you may approve
- the answer to a knock you made
The notification follows who can act, not who is present. A comment between two other members on a third member’s item raises nothing. Neither does a join request on a space you are only a member of.
A key that does not match is refused, not retried. Since any member may distribute, a
recipient checks the ring against spaces.key_fingerprint - written by the owner alone when
it mints - before adopting it. A correctly wrapped wrong key unwraps fine and then decrypts
nothing, which is the failure this catches. Nothing about waiting fixes it, so the client
reports it (space:key-rejected); the owner removing whoever sent it rekeys the space.
3. Who pays for what
Section titled “3. Who pays for what”Ownership decides cost the same way it decides editing: an account is charged for what it uploaded, and for nothing else.
| Resource | Charged to | A member who received it |
|---|---|---|
| Image bytes in R2 (50 MB quota) | the uploader | costs them nothing - no blobs row exists |
A sync_entries row (3,000 per account) |
the author | costs them nothing - it is the author’s row |
| Entry ciphertext (512 KB per entry) | the author | n/a |
A shared image is never copied. The reader gets a one-hour presigned GET on the owner’s key, granted only while a live entry carries that blob into a space they both belong to. Two consequences follow, and both are intended:
- The owner deleting the entry breaks it for everyone. The blob is released, the hourly sweep removes the object, and a member who had not fetched it yet gets a 404.
- Removing a member is not retroactive for anything they already pulled. See “What the server does not enforce” below - the same limit applies to bytes.
Backend detail: orange-copy-paste-clipboard-backend/docs/architecture.md sections 2.4
and 6.3.
4. Where each rule lives
Section titled “4. Where each rule lives”Client, Rust - the enforcement that matters, since only Rust can push:
sync/mod.rs-is_remote_entry, the early return inspawn_push_note/spawn_push_clipboard_entry(the “push guard” above), and theis_remotebranch inspawn_delete_entrythat keeps a removal local.sync/mod.rs-row_is_authoritative/author_of, the receiving half: a pulled row naming an account other than the entry’s author is dropped whole. The push guards stop this device creating a rival row; this stops one that already exists taking an entry over.sync/commands.rs-sync_push_entries/sync_unpush_entriesskip entries you did not write, and count only what they acted on.sync/types.rs-RemovalScope, andsync/mod.rs- theOwnedOnlyrefusal at the top ofspawn_delete_entry. The scope travels with the call, so an account-wide action cannot reach another member’s entry by forgetting to filter;IdMap::owned_keysis the list it should have been built from.sync/commands.rs-sync_unpush_all, the account-wide sweep, applies both.sync/id_map.rs-mark_unpushed/is_unpushed, and the guard at the top of the tombstone branch inmerge_pulled. This is what makes “keep the local copy” true when the device’s own tombstone comes back.notes/commands.rs-update_noterefuses outright.sync/commands.rs-space_comment_add, which delegates toadd_space_commentinsync/mod.rs; its refusal is built onspace_current_keyreturningNone. Commenting needs something readable to comment on, so it is gated on the key. Sharing is not:space_set_entry_sharesrecords the intent either way,share_targetsdrops a keyless space on the way out, andflush_pending_sharessends it when the key lands.sync/mod.rs-stamp_keys, which setsSpace::has_keyfrom the live keyring on every read. It is what the UI below draws from, and it is stamped rather than stored because the key can arrive at any moment.sync/mod.rs-reconcile_spaces, which decides who may hand a key over (any keyholder), who may mint one (the owner), and refuses a ring whose newest key does not match the space’s published fingerprint.sync/mod.rs-note_comment, the one place that decides a comment is worth interrupting for: not ours, and either on an entry we wrote or naming us.note_space_readableandnote_space_key_rejectedare the other two rows.sync/mod.rs-note_join_requested, raised only for a space whosei_can_approveis set, andnote_join_approvedfor the requester’s own side.wrap_ring_foris the shared half: approving and pre-wrapping an invite are the same operation, handing a key to somebody who is not a member yet.
Client, React - hides what is not allowed, so nothing dead is on screen:
hooks/useSpaceShares.ts-useRemoteEntryKeys()is the one source for “someone else wrote this”.ClipboardScreen.tsx,NotesScreen.tsx- passundefinedfor the cloud and share callbacks on others’ entries; bulk actions filter toselectedOwnIds.NoteEditor.tsx-readOnly+ownerName.SpacesScreen.tsx-canRemoveKey(is_owner || !remote) gates both the feed’s remove item and what bulk select will act on.comments/CommentThread.tsx- a comment’s delete button is drawn onis_mine || isOwner. Commenting itself is ungated in the UI: everyone in the space may, so there is nothing to hide.SpacesScreen.tsx,card-menu/CardMenu.tsx,clipboard-screen/bulk-actions/BulkActionsBar.tsx-space.has_keymarks a space still waiting on its key rather than disabling it. The share rows stay clickable and say “waiting”, and the space panel explains the wait once.useSpaceShares.waitingNamesFordims the entry’s share chip until it goes out.
Backend - the only place a rule survives a modified client:
spaces/service.py-remove_entry_from_spacenarrows to the caller’s own rows for non-owners, and answers a non-member with the same 404 as a missing entry; owner-only checks on the rest of the space routes.spaces/service.py-distribute_keyslets a non-owner write only a member row that holds no wrap, and never the owner’s row; everything else in the request is skipped.sync/service.py-_require_memberships: a push that adds a space the caller is not a current member of is refused (422not_a_member). This is what “Share an entry into the space” rests on server-side.sync/service.py-_require_own_blob: a push may only name a blob the caller uploaded and confirmed;blobs/service.py:_shares_space_with_blobonly counts rows written by the blob’s owner.spaces/service.py-_require_memberis the whole gate for reading and writing comments: a space is a room, and everyone in it can talk.delete_commentis the narrower one, author or space owner.spaces/service.py-may_approveis the single definition of who may answer a join request (owner, or member withmembers_can_approve). It is whatjoin_requests._require_approvergates on and whatSpaceOut.i_can_approveis derived from, so the client renders the rule rather than restating it.spaces/join_requests.py- a code or a join link now raises a request instead of a membership. This is the one place a leaked code is stopped, and it covers links by construction:/join/{code}only hands the code to the app, which calls the samePOST /spaces/join.
5. What the server does not enforce
Section titled “5. What the server does not enforce”The server never sees plaintext, so it cannot tell a good edit from a bad one. Everything above about pins, groups, and read-only editing is client-side, and a modified client can ignore all of it.
Authorship: the server-side exception
Section titled “Authorship: the server-side exception”A push that would insert or update a row for a client_id another account
already holds in a space the push adds is refused with not_your_entry
(sync/service.py:_belongs_to_someone_else).
- Why the server can enforce it. It needs no plaintext: the rule is about which account owns a key, not what the content says.
- Why it must be the server. The client half cannot be sufficient. Bug #8
(see the client’s
docs/bugfix-history.md) came from a client that pushed such a row, and every member’s client then had to defend against it on the way in. Old builds keep running, so the write has to be refused at the only point every client shares. - Why only on overlap. Two accounts belonging to one person hold the same
client_ids by construction, and neither impersonates anyone. Only when both rows land in the same space is one of them claiming to be the other.
What stays out of reach
Section titled “What stays out of reach”The server still cannot tell whether the author’s own edit is one the author meant. The guarantee is narrower: nobody can write into anyone else’s row.
6. Open
Section titled “6. Open”- Removing your copy of a shared entry. Handled by local delete. There is no
separate “stop showing me this” - deleting locally is that, and it leaves a
local_onlyplaceholder in the Spaces feed. - Getting an entry back after unsharing it. Works. A removal takes the space
id off the row, and pull only matches rows still carrying one of your spaces.
So a row that arrives with a space it was removed from is the author sharing it
again, and the merge clears the marker rather than blocking on it. A copy you
dropped yourself (
local_only) keeps blocking, since nothing about the space changed.
7. Recovery code
Section titled “7. Recovery code”Account-scoped, never space-scoped: a recovery code opens the account’s own encryption key and has nothing to do with membership of anything.
| Action | Who can do it | Enforced where |
|---|---|---|
| Save a recovery code | the account holder, on a signed-in device | sync_create_recovery_code needs the UMK in memory; PUT /auth/umk/recovery needs the account’s own JWT and, once the account has one, its UMK proof |
| Regenerate one | the same | the same route; storing replaces the envelope, so the previous code stops working |
| Use one | anyone holding the code, on any machine | client-side only - the server stores a blob it cannot open |
| Clear one | the account holder | DELETE /auth/umk/recovery, used when an account starts over with a new key |
There is deliberately no route that lets the server, an admin, or another member recover somebody’s key. That would end the end-to-end guarantee.
8. Key material and devices
Section titled “8. Key material and devices”A bearer token proves who signed in, not that the caller holds the account’s key. So
every write that changes key material also needs the UMK proof (X-Umk-Proof), and
every device-scoped route needs a device that is still the caller’s. The rule itself is
in the backend’s docs/architecture.md (section 7.1 and the API conventions).
| Action | Who can do it | Enforced where |
|---|---|---|
| Replace the password envelope, save or clear the recovery envelope | the account holder with the UMK unlocked | backend auth/service.py enforce_umk_proof; trust on first use for accounts with no proof yet |
| Wrap the UMK for a device | the same, and only for an unrevoked device of the same account | backend store_wrapped_umk |
| Revoke a device | the same | backend revoke_device; the device is refused at once (device_revoked) and its sockets are closed |
| Start over with a new key | whoever opened a Supabase password-recovery link for the account | backend set_wrapped_umk with reset: true, gated on the token’s amr method recovery |
| Call a device-scoped route or open the socket | the account’s own, unrevoked device | backend dependencies.py check_device |
Related: the routes and payloads behind each row are in the backend’s
docs/architecture.md, and
the client side of each action is in the app’s
docs/architecture.md.