Ownership and drafts
How the SDK decides which items the user may edit, publish and delete: owned and watched addresses, each item's publisher, drafts, and claiming.
The local database holds the user's own drafts and published items alongside other people's synced content, so the SDK needs a rule for what the user may change. That rule compares two things: the owned addresses you've told the client about, and each item's publisher.
Owned and watched addresses
The client keeps two lists of addresses in the local database:
- Owned addresses are accounts the user controls. Their items are the user's to edit, publish, unpublish and delete.
- Watched addresses belong to anyone else whose content the app wants to show. Their published items are synced from EAS, but they're read-only.
You set both with client.init({ addresses }) or client.setAddresses(), and the SDK syncs published items for both lists from EAS. Addresses are stored lowercased, and comparisons ignore case. Three details catch people out:
setAddressesreplaces both lists. Passing{ owned: [a] }clears the watched list. When@seedprotocol/publishregisters a connected wallet, it replaces the owned list and keeps the watched list.- Leaving
addressesout ofinitkeeps the stored lists, and empty lists clear them, with an exception for tabs opened while another is running. Client and configuration has the details. - Disconnecting clears the owned list only through
@seedprotocol/publish.ConnectButton's disconnect,useSeedWallet().disconnectandclearPublishWallet()set the owned list to empty and keep the watched list. Without the publish package, owned addresses stay stored until you change them.
The owned list is your app's statement about the user. The SDK doesn't check that the user holds a key for any of those addresses; the chain does that when they publish or revoke. See Identity and signing.
Every item has a publisher
Each item's Seed row has a publisher column, set in one of three ways:
- Synced items take the attester EAS reports: the account that published them.
- Items published from this device take the publishing account's address once the attestations land.
- Drafts take an address when they're created.
@seedprotocol/publishsupplies the connected wallet's publishing account (the ManagedAccount in the default setup). With no wallet connected, or without the publish package, a draft gets the owned address when exactly one is set, and no publisher otherwise.
A draft's versions and property values take the same publisher as the draft. A draft here means an item that has never been attested: it has no real EAS UID and no stored attestation.
How ownership is decided
isItemOwned(item) applies one rule, and everything that checks ownership uses it:
A draft with no publisher belongs to no wallet yet, so it's the user's whatever the owned addresses are. That's what lets someone start writing before they've connected anything, and keep editing after a reload before the wallet reconnects.
Every other item, including a draft that has a publisher, is the user's only while its publisher is in the owned list. If a row has no publisher but has a stored attestation, the attestation's attester is used instead. So a draft created with one wallet is read-only, and left out of owned lists, while another wallet is connected or none is. Watched addresses never grant ownership.
Item.all() and useItems with addressFilter: 'owned' list exactly the items this rule counts as owned. See Item.all.
Claiming drafts
client.claimUnpublishedDrafts(address) gives drafts with no publisher to address: it stamps it on those drafts, their versions and their property values, including items whose Item.create write is still in the outbox, and returns how many rows and queued creates it changed. Drafts that already have a publisher, and anything attested, are left alone. From then on the claimed drafts follow the second half of the rule, so they're read-only while another wallet is connected.
@seedprotocol/publish claims for the connected wallet's publishing account whenever a wallet connects through ConnectButton or setPublishWallet, so most apps never call it. Without the publish package, call it after setAddresses. Item: claimUnpublishedDrafts has the call.
Two things to know about claiming:
- It claims every unclaimed draft in the database, whoever wrote it. If two people use the same browser profile without a wallet, the first to connect claims both sets of drafts.
- Loaded items see it. An item already in memory gets the new
publisherstraight away, and other tabs follow.
Where ownership is enforced
Destroying, publishing and unpublishing an item, and saving or destroying a property value, check isItemOwned first and reject with an OwnershipError when it's false. Assigning a value is checked when its write is applied, and doesn't throw: on an item the user doesn't own, post.title = 'x' adds a write to the outbox that fails there, nothing is written, and a moment later the value shows the stored one again. Because the check runs then, a write queued in another tab, before a reload or before a wallet change fails if the item isn't owned by the time it's applied. Item.create isn't checked, except for the Html, File, Image and ItemStorage values it saves through the same path as an edit; if one of those is refused, the create rejects and leaves no draft behind. Item: Ownership lists each check.
These are local checks. They keep your UI honest, but what protects published content is on-chain: only the attesting account can revoke its attestations, so an app can't unpublish a watched user's items. See Revocation stays with the owner.
What this means for your code
- Pass the user's addresses to
initat each start once you know them, and callsetAddresseswhen they change while the client is running, such as when a wallet connects. A repeatinitdoesn't change them. Leaveaddressesout only when you mean to keep the stored lists. - Claim drafts yourself only without
@seedprotocol/publish. Unclaimed drafts are editable anyway; claiming ties them to an account before someone else uses the device. Only drafts made while no single owned address was set need it, because with exactly one, new drafts get it when they're created. - Gate edit controls on
isItemOwned. A refused assignment shows the stored value again without throwing, so a check up front gives a clearer UI than a value that flips back. - Plan for shared devices. On sign-out,
client.removeLocalCopiesForAddresses()removes the local copies of an account's published items (drafts are kept, and nothing changes on-chain). While the account is still owned, a copy with unpublished edits is kept too and listed inkeptSeedLocalIds; pass{ includeUnpublished: true }to remove it as well. Clear the owned list yourself, unless the user signs out through@seedprotocol/publish, which clears it. Drafts with no publisher stay editable by whoever uses the device next.
Related
- Item:
isItemOwned,claimUnpublishedDraftsand the methods that check ownership - Client and configuration:
setAddresses,getAddresses, init andsetGetPublisherForNewSeeds - Identity and signing: which account is the publisher
- Permanence: what publishing and revoking change
- Syncing across devices: owned and watched addresses in practice
Identity and signing
Whose account a published item belongs to, who signs the attestations and the Arweave uploads, who pays, and how delegated keys fit in.
Permanence
What becomes permanent when you publish with Seed Protocol, what doesn't, and what 'permanent' means while the protocol runs on a testnet.