Skip to content

Tasks: collaborate on lists through KV shares - #80

Merged
jelveh merged 3 commits into
HeyPuter:mainfrom
Salazareo:tasks/kv-share-collab
Oct 26, 2026
Merged

jelveh merged 3 commits into
HeyPuter:mainfrom
Salazareo:tasks/kv-share-collab

Conversation

@Salazareo

Copy link
Copy Markdown
Member

Collaborative lists in Tasks were built on the assumption that Puter storage is strictly per-account and that there is no inbox:

  • every participant published a replica of the list as a public read URL, valid for up to a year
  • the owner kept a manifest of members
  • joining took a two-link handshake

Puter's KV shares (HeyPuter/puter#4331) make that unnecessary, and they fix three problems with it:

  • Anyone holding a replica or manifest link could read the list.
  • Revoking a member didn't cut their access.
  • Every join needed the manual confirmation step.

How it works now

  • Data: the owner keeps the list in their own KV under tasks:collab:<id>:. That prefix holds meta (name, icon, colour, owner username), one t:<taskId> key per task, and one d:<taskId> tombstone per deletion. Each collaboration gets a fresh id, so restarting one never picks up keys or shares an earlier one left behind.
  • Invite: the owner invites by Puter username. The first invite asks for consent, perms.request('kvShare', { prefix: 'tasks:collab:', access: 'write' }), or the permission string on older SDKs. The list is then shared at write, with its cleaned name as the label.
  • Discovery: invitees find new lists through listShared() at startup, on the kv.share.received notice, and on focus or poll. No link is involved.
  • Sync: reads and writes go through the handle and use the existing merge rules (ids, updatedAt, tombstones). Shared data can't touch other lists.
  • Live updates: the owner watches kv:<appID>:<prefix>* and members watch kv:<handle>:*. Polling stays as a fallback.
    • A watched list still gets a sync every five minutes. Revoking a share stops its deliveries without ending the subscription, so only a sync shows that the handle is gone.
  • Ending access: removing a member, stopping collaboration (revokes every share and clears the prefix) and leaving all go through unshare. A member whose handle stops working gets a private copy, the same as today's ended collaborations.

Compatibility

  • New collaborations use KV shares where the SDK has kv.share.
  • The old link protocol is used when the SDK lacks it, the page can't ask for consent, or the deployment has shares turned off. If shares turn out to be off on the first invite and nobody has joined yet, the sheet switches to the invite link.
  • Lists already collaborating the old way stay on it, unchanged.
  • To move one over, the owner stops collaborating and invites people again. Old lists aren't linked to new shares automatically: meta is writable by any member, so a former member could use it to take over someone's list.

Worth a look from reviewers

  • Shared lists appear without an accept step, with a "X shared Y with you" toast. Puter lets a user block someone or stop accepting shares, but anyone can still put a list in a stranger's sidebar. If we'd rather have pending invitations, that's a follow-up.
  • Only write shares are adopted; read and watch shares are ignored.
  • The share label isn't updated on rename; members read the current name from meta.

Testing

  • New tests/kvshare.test.js (24 tests) covers:
    • sharing, consent and denial, the deprecated permission string, and label cleaning
    • invite errors and discovery (no duplicates, second device, notification)
    • hostile meta and tampered keys
    • edits in both directions, conflicts and rename
    • live events, and a revoked share on a watched list
    • revoke, a dead handle becoming a private copy, stop, leave, a failed leave being retried, and re-invite
    • SDKs without shares, deployments with shares off, and an old-protocol list on a new SDK
  • The fake Puter adds shares behind createMockPuter({ kvShares: true }). Holders are usernames, and errors are { message, code } without status, as the real API returns them.
  • Full suite passes (235 tests), and npm run build succeeds. The owner flow was tried in Chrome with the dev mock (?kvshare); the member flow is covered by tests only.

Collaborative lists were built on public read-URL files: every member
published a replica, the owner kept a manifest, and joining took a
two-link handshake. Anyone holding a replica or manifest URL could read
the list for up to a year, and removing a member did not cut their
access.

New collaborations now keep the list under its own prefix in the
owner's KV store (meta, one key per task and per deletion) and share it
at write level with each invited username via puter.kv.share, after
asking for kvShare consent (the permission-string form on older SDKs).
Members discover shares with listShared() and on the kv.share.received
notification, read and write through the handle, and merge with the
existing id/updatedAt/tombstone rules. Changes arrive through kv event
subscriptions, with polling for lists without one. Removing a member,
stopping, and leaving are unshare calls, retried until they land; a
member whose handle stops working keeps a private copy.

Lists already on linked replicas keep working. SDKs without kv.share,
pages that can't ask for consent, and deployments with shares turned
off fall back to linked replicas. The test mock gains KV shares,
handle-scoped kv ops, events and consent.
The share sheet asked owners to pass an invite link and approve a
confirmation link. For lists shared over KV shares it now has a
username field, lists who has access from listSharedByMe, and says that
removing someone cuts access right away; there is no link step for
members, whose app adopts the list and toasts who shared it. If the
deployment refuses KV shares, the sheet switches to the invite link.

The app starts the share watch after boot, looks for new shares on
focus, and polls collab lists every minute while visible. The dev mock
gains ?kvshare to try the owner flow, and the README describes the new
storage layout and the fallback.
The poll skips KV lists whose live subscription delivers their changes,
but revoking a share only stops its deliveries: the subscription stays
open, so a removed member's list never turned into a private copy until
the next focus. A watched list is now still synced by the poll once it
has gone five minutes without a sync.
@jelveh
jelveh merged commit 2d16688 into HeyPuter:main Oct 26, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants