# Morse Documentation: Complete # Morse is an open, decentralized headless CMS protocol built on Sui and Walrus. # Index: https://docs.morsecms.xyz/llms.txt · Site: https://docs.morsecms.xyz/ # Feedback: https://github.com/arcadiasystems/morse-dcms/issues/new?labels=docs-feedback # Morse: The Decentralized Headless CMS Protocol > A headless CMS protocol built on Sui and Walrus, for developers building content applications with on-chain ownership and decentralized storage. > **WARNING: Network status** > > Morse runs on **Walrus Mainnet and testnet**. On mainnet, gas and Walrus storage cost real SUI and WAL, and there is no faucet. Test on testnet first, and read [Walrus Mainnet](https://docs.morsecms.xyz/mainnet.html) before you switch. Sui and Walrus testnets reset periodically (Walrus wipes every few months), so stored content and object IDs are lost on reset. Do not publish anything you cannot afford to lose. ## What is Morse? Morse lets developers build content applications (blogs, knowledge bases, gated media platforms) where the backend is an open protocol instead of a vendor service. You define content structures, set access policies, and publish through a CLI or TypeScript SDK; the contracts handle identity, permissions, and revision history so there is no central point of failure to trust or migrate away from. ## Why Morse? Traditional headless CMSs create several long-term constraints: - **Trust concentration**: one vendor backend controls all identity and data - **Weak portability**: publication identity and permissions do not travel with you - **Opaque history**: change and authorization history is hidden inside a closed system - **Vendor lock-in**: migrating requires re-building your backend from scratch Morse solves these while keeping the ergonomics of headless CMS architecture. Publication identity is canonical on-chain. Write permissions are capability-based and verifiable. Every revision is auditable. And any team can integrate directly with the contracts, bypassing any specific service provider. ## System architecture Morse is composed of five layers. Only the contracts are the source of truth; all other layers are replaceable. - **Control**: Sui Blockchain · Publication Registry · Collections · Entries & Revisions · OwnerCap / PublisherCap - **Storage**: Walrus · Blob Storage · Quilt Storage - **Access**: Seal · Publisher Policy · Allowlist Policy - **Query**: Indexer · Content API - **Develop**: TypeScript SDK · CLI > **INFO: The Morse Book** > > For the full architecture, from what a headless CMS is to how each layer of Morse fits together, read [The Morse Book](https://app.notion.com/p/thedivic/The-Morse-Book-2f3832ac2ab680e5a403f1dde1e8a900) on Notion. ## Key features - **On-chain Permission Management**: Publication identity, write permissions, and state transitions are enforced by Move smart contracts: transparent and auditable. - **Walrus Storage**: Content payloads and assets live in Walrus decentralized storage. Contracts store verifiable references, not bytes. - **Capability-Based Auth**: OwnerCap and PublisherCap separate permission management from publishing. Revocation is instant and denylist-based. - **Immutable Revisions**: Every content change is an appended revision. Draft and public heads enable editorial workflows without losing history. - **Access Policies**: Public, publisher-gated, and subscription access policies control payload retrieval via Seal encryption. - **Open Protocol**: Anyone can build integrations directly on contracts. Off-chain services are optional ergonomics, not gatekeepers. - **Agent-First by Design**: A coding agent can provision publications, publish content, and operate the entire backend via CLI or SDK. No centralized provisioning required. ## Component status | Component | Status | Description | | --- | --- | --- | | **Move Contracts** | Active | Publication, collection, entry, and revision management. Deployed on testnet and [mainnet](https://docs.morsecms.xyz/mainnet.html). | | **TypeScript SDK** | Active | Wraps Sui, Walrus, and Seal behind a small adapter pattern. Published as `@arcadiasystems/morse-sdk` on npm (testnet and mainnet). | | **CLI** | Active | Terminal tool for managing publications, collections, and entries. Published as `@arcadiasystems/morse-cli` on npm (testnet and mainnet). | | **Indexer** | Planned | Listens to on-chain events and builds read-optimized state. | | **Content API** | Planned | REST API for querying publications, collections, and entries. | ## Next steps - [Quick Start](https://docs.morsecms.xyz/quick-start.html): Choose your path (CLI, SDK, or contracts) and go from zero to published content on testnet. - [Live Demo](https://morse-blog-demo.vercel.app): A working blog running on Morse: wallet-native publishing, on-chain revisions, Seal-gated premium posts. No wallet needed to read. - [Core Concepts](https://docs.morsecms.xyz/core-concepts.html): Understand the Publication → Collection → Entry model. - [CLI](https://docs.morsecms.xyz/cli-overview.html): Manage publications and content from your terminal. Encrypted keystore, active context, no IDs to paste. - [SDK](https://docs.morsecms.xyz/sdk-overview.html): Build on Morse from Node or the browser. Adapter pattern, branded IDs, structured errors. - [React Uploader](https://docs.morsecms.xyz/uploader.html): Drop-in React components and hooks to upload files to Walrus, public or encrypted, and share them with a link. --- # Core Concepts > Understand the fundamental building blocks of the Morse protocol: the data model, authorization system, storage strategy, and how on-chain and off-chain components interact. ## The data model Morse organizes content in a four-level hierarchy. Each level is stored on-chain and has a clear responsibility: - **Level 1**: Publication - **Level 2**: Collection - **Level 3**: Entry - **Level 4**: Revision ### A concrete example Imagine you run a team blog. Here is how it maps to the Morse data model: - **Publication** `team-blog`: your brand-level content domain, registered with a unique slug - **Collection** `articles`: one content lane for blog posts (blob mode, since each post is independent) - **Entry #0** `welcome-post`: your first blog post, assigned the monotonic ID `0` - **Revision 0**: the initial published version. `public_head` points here, so readers see it immediately - **Revision 1**: an updated draft. `draft_head` points here, but `public_head` still points to revision 0; readers see the original until you explicitly promote the draft Editors operate mostly at the **Entry / Revision** level. Owners operate mostly at the **Publication / Authorization** level. ## Publications Think of a publication as your project or site. It is the top-level namespace that groups all your content together. Every publication gets a unique slug (like `my-blog`) that acts as a human-readable identifier across the protocol. All collections, entries, and permissions live under this single umbrella. [See Publications for the full reference →](https://docs.morsecms.xyz/publications.html) ## Collections Collections are how you organize entries within a publication, similar to folders or content types. For example, a blog might have an `articles` collection and a `site-pages` collection. When you create a collection, you choose how its content is stored: individually as separate blobs (good for standalone posts or images) or as a shared document that can be patched in place (good for structured data like settings or configuration). [See Collections for the full reference →](https://docs.morsecms.xyz/collections.html) ## Entries & revisions Entries are the actual content pieces: a blog post, an image, a page. Every edit creates a new revision rather than overwriting the previous one, giving you a complete version history whose on-chain record is immutable. The system tracks two pointers for each entry: a draft head for work-in-progress and a public head that readers see. This lets drafts evolve privately while production clients serve stable published content. [See Entries for the full reference →](https://docs.morsecms.xyz/entries.html) ## How publishing works End-to-end flow when you publish content: 1. **Authenticate**: hold a `PublisherCap` for the target publication 2. **Upload**: upload content bytes to Walrus, get a blob reference 3. **Register**: create an entry on-chain storing the blob reference, content type, and access policy 4. **Heads update**: public_head and/or draft_head update based on encryption status 5. **Read**: any client resolves the entry and fetches the payload from Walrus ## Capability-based authorization Instead of usernames and passwords, Morse uses on-chain capability objects to manage permissions. Owning the right object in your wallet proves you have permission to act. No login server required. Morse separates ownership from publishing using two types of capability tokens. An `OwnerCap` (one per publication) controls administrative operations like issuing permissions and deleting the publication. `PublisherCap`s (many per publication) grant write access and are bound to a specific holder address. Revocation uses a denylist-based model: issuing caps is cheap (zero table writes), and only revocations write to the denylist. Once revoked, all future writes using that cap are immediately rejected. [See Access Control for the full reference →](https://docs.morsecms.xyz/access-control.html) ## Walrus storage The Sui blockchain only stores references (pointers) to your content, not the content bytes themselves. The actual payloads live on Walrus, a decentralized storage network designed for large data. This separation keeps on-chain costs low while keeping your content verifiable: payloads stay available for the storage period you fund (measured in Walrus epochs) and can be renewed at any time. Walrus is format-agnostic, so Morse can support JSON, Markdown, images, video, or any custom schema. [See Collections → Storage modes for storage mode details →](https://docs.morsecms.xyz/collections.html#storage-modes) ## Access control Morse can gate content behind on-chain encryption using Seal. Only users who meet specific on-chain conditions can decrypt and read the content. This means access rules are enforced by the blockchain itself, not by a server you have to trust. This approach enables paid subscriptions, token-gated access, team-only content, and other models, all without a centralized gatekeeper. [See Access Control for the full reference →](https://docs.morsecms.xyz/access-control.html) ## Off-chain services The blockchain is the canonical source of truth, but off-chain services improve the developer experience. Morse provides optional infrastructure like an indexer (for fast search and queries) and a content API (for read access) so you do not have to query the chain directly. These services make building apps on Morse faster and simpler, but they are not required. - **Indexer**: listens to on-chain events and builds a read-optimized state database - **Content API**: serves fast queries for listings, slug resolution, and payload references - **SDK**: TypeScript package that wraps Sui/Walrus/Seal interactions for app developers - **CLI**: command-line tool for scripted automation, CI, and agent-native workflows Importantly, all off-chain services are replaceable. Any team can run their own indexer, API, or integrate directly with the contracts via the SDK. Protocol access is never gated by a single service provider. ## Next steps - [Quick Start](https://docs.morsecms.xyz/quick-start.html): Choose CLI, SDK, or Smart Contracts and go from zero to published content on testnet. - [Publications](https://docs.morsecms.xyz/publications.html): Full reference for the top-level content container: slugs, capabilities, and lifecycle. - [Collections](https://docs.morsecms.xyz/collections.html): Storage modes, entry IDs, and collection management. - [Access Control](https://docs.morsecms.xyz/access-control.html): OwnerCap, PublisherCap, revocation, and Seal encryption. --- # Quick Start > Choose the path that fits your workflow. Each guide takes you from zero to published content on testnet. > **INFO: New to Morse?** > > Start with [Core Concepts](https://docs.morsecms.xyz/core-concepts.html) for an overview, or jump straight into the CLI guide. It is the fastest way to see Morse in action. - [CLI](https://docs.morsecms.xyz/quick-start-cli.html): Install morse-cli from npm, create a publication, publish and read content, all from your terminal. For operators and content managers. *(~5 min · Bun or Node · funded Sui wallet)* - [TypeScript SDK](https://docs.morsecms.xyz/quick-start-sdk.html): Wire up @arcadiasystems/morse-sdk, create a publication, add an entry and read it back in a single script. For app developers building on Morse. *(~5 min · Bun or Node · funded Sui wallet)* - [Smart Contracts](https://docs.morsecms.xyz/quick-start-contracts.html): Interact with Morse's deployed contracts directly via PTBs. For developers who want full control over transaction construction. *(~10 min · Sui CLI · funded Sui wallet)* ## Every path needs a funded testnet wallet All three guides sign transactions on Sui testnet and store content on Walrus testnet. You need SUI for gas and WAL for storage. Both are free and take about two minutes. | Step | Where | Why | | --- | --- | --- | | 1. Install a wallet | Sui Wallet, Suiet, or any Sui-compatible wallet | Holds your key and signs transactions | | 2. Get testnet SUI | [faucet.sui.io](https://faucet.sui.io/) | Gas for every on-chain action | | 3. Swap some SUI for WAL | [stake-wal.wal.app](https://stake-wal.wal.app/?network=testnet) | Walrus storage fees when you publish | > **WARNING: Testnet first** > > Testnets reset periodically. Testnet tokens have no monetary value. Morse also runs on Walrus Mainnet, where gas and storage cost real SUI and WAL and there is no faucet. Run each guide on testnet before you try mainnet. --- # Quick Start: CLI > Install morse-cli and go from zero to published content on testnet in under five minutes. No contracts, no code, just your terminal. - About 5 minutes - Bun or Node - Funded Sui wallet - Sui testnet > **WARNING: Network status** > > Morse runs on **Walrus Mainnet and testnet**. On mainnet, gas and Walrus storage cost real SUI and WAL, and there is no faucet. Test on testnet first, and read [Walrus Mainnet](https://docs.morsecms.xyz/mainnet.html) before you switch. Sui and Walrus testnets reset periodically (Walrus wipes every few months), so stored content and object IDs are lost on reset. Do not publish anything you cannot afford to lose. ## Prerequisites | Requirement | Details | Purpose | | --- | --- | --- | | Bun or Node | v1.2+ / ≥ 18 | JavaScript runtime | | Sui wallet | Sui Wallet, Suiet, or any Sui-compatible wallet | Signing transactions on testnet | | SUI + WAL tokens | [SUI faucet](https://faucet.sui.io/) · [Swap SUI for WAL](https://stake-wal.wal.app/?network=testnet) | Gas fees (SUI) and Walrus storage fees (WAL) | | Bech32 private key | A `suiprivkey1...` string | CLI authentication. Export from your wallet's settings | ## Installing Install the CLI globally using your preferred package manager. ```bash npm i -g @arcadiasystems/morse-cli ``` ```bash bun add -g @arcadiasystems/morse-cli ``` Verify the installation: ```bash morse --help ``` ## Creating a profile A profile stores your network, RPC endpoint and active context. Create one for testnet: ```bash morse config add testnet --network testnet ``` Verify your profile is active: ```bash morse status ``` ## Importing your key Import a Sui private key into the encrypted keystore. The CLI prompts interactively for the key and a password. Keys are never passed as flags. ```bash morse account import ``` > **INFO: Key format** > > Paste a Bech32 `suiprivkey1...` string. Export one from your wallet's settings. For non-interactive environments (CI, scripts), set `MORSE_PRIVATE_KEY` instead; the CLI uses it directly and skips the keystore prompt. ## Creating a publication This creates a publication with a globally unique slug and auto-selects it as the active context. ```bash morse publication create --name "My Blog" --slug "my-blog-$(date +%s)" ``` > **INFO: Slugs are globally unique** > > Like usernames, a slug can only be registered once on the whole network. The timestamp suffix keeps this command collision-free for everyone following this guide. For a real project, pick any unused slug. If a slug is taken, the transaction aborts with `ESlugAlreadyExists`. ```output Creating "My Blog"... Created "My Blog" (0x23d2...) ownerCap: 0xa40a... publisherCap: 0x6141... tx: 7nF2n... Selected as the active publication. ``` > **INFO: Which IDs to save** > > The CLI auto-selects new publications and collections as your active context, so you rarely need to paste IDs. If you need them later, run `morse status` to see your active publication ID and collection. ## Creating a collection Collections group entries within a publication. The CLI auto-selects the new collection as your active context, so subsequent entry commands target it automatically. ```bash morse collection create posts ``` ```output Creating collection "posts" (blob)... Created collection "posts". Selected as the active collection. (tx: DWkD...) ``` ## Publishing an entry Create a content file and publish it. The CLI uploads the file to Walrus and registers the entry on-chain. > **WARNING: This step needs WAL, not just SUI** > > Everything up to here only cost SUI gas. Uploading to Walrus also spends WAL. Make sure your account holds both before running this (swap SUI for WAL at [stake-wal.wal.app](https://stake-wal.wal.app/?network=testnet)), or the upload fails with `Insufficient WAL balance`. ```bash echo '{"title": "Hello World", "body": "My first post on Morse."}' > post.json ``` Publish it. The CLI targets the active publication and collection by default. You can override with `-P ` and `-C `: ```bash morse entry add hello --file post.json ``` ```output Uploading 65 bytes to Walrus... ✔ Added entry #0 "hello" (tx: D9Yfjk...) view: https://aggregator.walrus-testnet.walrus.space/v1/blobs/0iKeii... ``` ## Reading it back Fetch the entry content by its ID. Entry IDs are monotonic; your first entry is `0`. ```bash morse entry read 0 ``` `entry read` fetches the content from the Walrus storage nodes and checks it against the blob id recorded on Sui. See [CLI Cookbooks → Reading it back](https://docs.morsecms.xyz/cli-workflows.html#reading-it-back). ```output {"title": "Hello World", "body": "My first post on Morse."} ``` > **TIP: You are live on-chain** > > Your content is stored on Walrus and registered on Sui testnet. Run `morse status` to see your active context, or `morse entry list` to see all entries in the collection. If you hit errors at any step, run `morse --help` or see the [CLI Cookbooks](https://docs.morsecms.xyz/cli-workflows.html#troubleshooting) troubleshooting section. ## Next steps - [CLI: Command Reference](https://docs.morsecms.xyz/cli-commands.html): Every command and flag: publication, collection, entry, revision, cap. - [CLI: Cookbooks](https://docs.morsecms.xyz/cli-workflows.html): Multi-step recipes: encrypted entries, multi-writer setups, CI/CD scripting. - [Core Concepts](https://docs.morsecms.xyz/core-concepts.html): The data model: publications, collections, entries and revisions. --- # Quick Start: SDK > Create a publication, add an entry, and read it back in one TypeScript script. Server-side with KeypairAdapter: no browser, no wallet popups. Want to see the result first? Browse the [live demo blog](https://morse-blog-demo.vercel.app) built on this SDK. > **WARNING: Network status** > > Morse runs on **Walrus Mainnet and testnet**. On mainnet, gas and Walrus storage cost real SUI and WAL, and there is no faucet. Test on testnet first, and read [Walrus Mainnet](https://docs.morsecms.xyz/mainnet.html) before you switch. Sui and Walrus testnets reset periodically (Walrus wipes every few months), so stored content and object IDs are lost on reset. Do not publish anything you cannot afford to lose. ## Prerequisites | Requirement | Details | Purpose | | --- | --- | --- | | Bun or Node | v1.2+ / ≥ 18 | JavaScript runtime | | SUI + WAL tokens | [SUI faucet](https://faucet.sui.io/) · [Swap SUI for WAL](https://stake-wal.wal.app/?network=testnet) | Gas fees (SUI) and Walrus storage fees (WAL) | | Bech32 private key | A `suiprivkey1...` string | SDK authentication. Export from your wallet's settings | ## Installing Install the SDK and its peer dependencies using your preferred package manager. See [SDK Client Setup → Compatibility](https://docs.morsecms.xyz/sdk-client-setup.html#compatibility) for compatible dependency versions. The `@mysten/*` peers are pinned to the exact versions the SDK accepts: it takes one `@mysten/walrus` and one `@mysten/seal` release, because a later patch of either can need a newer `@mysten/sui` than the SDK supports. ```bash bun add @arcadiasystems/morse-sdk@~0.11.0 @mysten/sui@2.38.0 @mysten/walrus@1.2.37 @mysten/seal@1.5.2 ``` ```bash npm install @arcadiasystems/morse-sdk@~0.11.0 @mysten/sui@2.38.0 @mysten/walrus@1.2.37 @mysten/seal@1.5.2 ``` ```bash yarn add @arcadiasystems/morse-sdk@~0.11.0 @mysten/sui@2.38.0 @mysten/walrus@1.2.37 @mysten/seal@1.5.2 ``` `@mysten/sui` is a required peer dependency. `@mysten/walrus` is needed for the `DefaultWalrusWriteAdapter` used below. `@mysten/seal` is required at runtime even for non-encrypted workflows. ## Writing the script Save this as `quickstart.ts`. It creates a publication, adds a collection, uploads an entry, and reads it back. > **INFO: Transport: gRPC** > > This guide uses `SuiGrpcClient` from `@mysten/sui/grpc`; the SDK's client interfaces are typed against it. The JSON-RPC `SuiClient` from `@mysten/sui/client` is not supported, and the SDK makes no JSON-RPC calls. See [SDK Client Setup](https://docs.morsecms.xyz/sdk-client-setup.html) for details. ```typescript import { SuiGrpcClient } from "@mysten/sui/grpc"; import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519"; import { KeypairAdapter, morseConfig, RpcPublicationReader, createPublication, createCollection, addEntryFromBytes, DefaultWalrusWriteAdapter, HttpAggregatorReadAdapter, } from "@arcadiasystems/morse-sdk"; const privateKey = process.env.PRIVATE_KEY; if (!privateKey) throw new Error("Set PRIVATE_KEY to a suiprivkey1... Bech32 string"); // 1. Configure client and adapter const config = morseConfig({ network: "testnet" }); const client = new SuiGrpcClient({ network: "testnet", baseUrl: config.rpcUrl, }); const keypair = Ed25519Keypair.fromSecretKey(privateKey); const adapter = KeypairAdapter.fromSecretKey(privateKey, client); const reader = RpcPublicationReader.fromMorseConfig(config, client); // 2. Create a publication const pub = await createPublication(adapter, config, { name: "My Publication", slug: `my-pub-${Date.now()}`, // slugs are globally unique on-chain }); console.log("Publication:", pub.publicationId); // 3. Add a collection await createCollection(adapter, config, { publicationId: pub.publicationId, publisherCapId: pub.publisherCapId, name: "posts", storageMode: "blob", }); console.log("Collection created: posts"); // 4. Upload and add an entry const walrusWriter = DefaultWalrusWriteAdapter.fromConfig( { network: "testnet", suiClient: client }, keypair, ); const entry = await addEntryFromBytes(adapter, config, { walrus: walrusWriter, publicationId: pub.publicationId, publisherCapId: pub.publisherCapId, collectionName: "posts", name: "first-post", bytes: new TextEncoder().encode( JSON.stringify({ title: "Hello World", body: "My first post on Morse." }) ), contentType: "application/json", upload: { epochs: 3, deletable: true }, }); console.log("Entry ID:", entry.entryId); // 5. Read it back (metadata from Sui, content from Walrus) const fetched = await reader.getEntry( pub.publicationId, "posts", entry.entryId, ); console.log("Entry name:", fetched.name); console.log("Revisions:", fetched.revisions.length); const walrusReader = HttpAggregatorReadAdapter.fromMorseConfig(config, client); const content = await walrusReader.readBlobRef(fetched.revisions[0].blobRef); console.log(new TextDecoder().decode(content)); ``` `HttpAggregatorReadAdapter` routes content reads through the Walrus aggregator, which is more reliable on testnet. See [SDK Client Setup → Picking a Walrus adapter](https://docs.morsecms.xyz/sdk-client-setup.html#picking-a-walrus-adapter) for alternatives. > **TIP: Which IDs to save** > > The return values (`pub.publicationId`, `pub.publisherCapId`, `entry.entryId`) contain everything you need for subsequent calls. Log them or store them in your app's config. ## Running it ```bash PRIVATE_KEY="suiprivkey1..." bun run quickstart.ts ``` With Node (requires `"type": "module"` in your `package.json` for top-level `await`): ```bash PRIVATE_KEY="suiprivkey1..." npx tsx quickstart.ts ``` ```output Publication: 0x1a2b...3c4d Collection created: posts Entry ID: 0 Entry name: first-post Revisions: 1 ``` > **TIP: You are live on-chain** > > Your publication, collection, and entry are on Sui testnet. The entry content is stored on Walrus. For browser integration with wallet popups, see [SDK Client Setup → Browser](https://docs.morsecms.xyz/sdk-client-setup.html#browser-setup). ## Next steps - [SDK: Client Setup](https://docs.morsecms.xyz/sdk-client-setup.html): Browser setup, wallet adapters, Walrus adapter options, and error handling. - [SDK: Cookbooks](https://docs.morsecms.xyz/sdk-workflows.html): Encrypted entries, revisions, reading content, and the full publishing API. - [Core Concepts](https://docs.morsecms.xyz/core-concepts.html): Understand the data model: publications, collections, entries, and revisions. --- # Quick Start: Smart Contracts > Create a publication, add a collection, and publish an entry using raw Programmable Transaction Blocks against the deployed Morse contracts on testnet. No SDK wrappers, just `tx.moveCall`. > **WARNING: Network status** > > Morse runs on **Walrus Mainnet and testnet**. On mainnet, gas and Walrus storage cost real SUI and WAL, and there is no faucet. Test on testnet first, and read [Walrus Mainnet](https://docs.morsecms.xyz/mainnet.html) before you switch. Sui and Walrus testnets reset periodically (Walrus wipes every few months), so stored content and object IDs are lost on reset. Do not publish anything you cannot afford to lose. ## Prerequisites | Requirement | Details | Purpose | | --- | --- | --- | | Bun or Node | v1.2+ / ≥ 18 | JavaScript runtime | | SUI + WAL tokens | [SUI faucet](https://faucet.sui.io/) · [Swap SUI for WAL](https://stake-wal.wal.app/?network=testnet) | Gas fees (SUI) and Walrus storage fees (WAL) | | Bech32 private key | A `suiprivkey1...` string | Transaction signing. Export from your wallet's settings | ## Installing Install the Morse SDK (for network configuration and Walrus uploads) and the Sui SDK (for transaction construction and signing). The `@mysten/*` peers are pinned to the exact versions the SDK accepts: it takes one `@mysten/walrus` and one `@mysten/seal` release, because a later patch of either can need a newer `@mysten/sui` than the SDK supports. ```bash bun add @arcadiasystems/morse-sdk@~0.11.0 @mysten/sui@2.38.0 @mysten/walrus@1.2.37 @mysten/seal@1.5.2 ``` ```bash npm install @arcadiasystems/morse-sdk@~0.11.0 @mysten/sui@2.38.0 @mysten/walrus@1.2.37 @mysten/seal@1.5.2 ``` > **INFO: Why the SDK if we are using raw PTBs?** > > This guide uses raw `tx.moveCall`. No SDK wrappers for contract calls. The SDK packages provide three things: `morseConfig`: resolves the deployed package ID, registry ID, and RPC URL so you do not hard-code addresses `DefaultWalrusWriteAdapter`: handles blob uploads to Walrus (requires `@mysten/walrus`) `DefaultSealAdapter`: handles Seal encryption (requires `@mysten/seal`, loaded at runtime even for non-encrypted workflows) For the high-level wrapper approach, see the [SDK Quick Start](https://docs.morsecms.xyz/quick-start-sdk.html). ## Writing the script Save this as `quickstart-contracts.ts`. It creates a publication, adds a collection, uploads content to Walrus, and registers an on-chain entry, all using raw PTBs. > **INFO: Transport: gRPC** > > This guide uses `SuiGrpcClient` for its typed interface and lower latency. The Morse SDK is gRPC-only: its readers and adapters are typed against `SuiGrpcClient` from `@mysten/sui/grpc`, and it makes no JSON-RPC calls. Environments that block gRPC must proxy through a gRPC-compatible RPC endpoint. See [SDK Client Setup](https://docs.morsecms.xyz/sdk-client-setup.html) for details. ```typescript import { SuiGrpcClient } from "@mysten/sui/grpc"; import { Transaction } from "@mysten/sui/transactions"; import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519"; import { bcs } from "@mysten/sui/bcs"; import { morseConfig, DefaultWalrusWriteAdapter } from "@arcadiasystems/morse-sdk"; const privateKey = process.env.PRIVATE_KEY; if (!privateKey) throw new Error("Set PRIVATE_KEY to a suiprivkey1... Bech32 string"); // --------------------------------------------------------------------------- // 1. Configure client, keypair, and Walrus adapter // --------------------------------------------------------------------------- const config = morseConfig({ network: "testnet" }); const PACKAGE_ID = config.packageId; const REGISTRY_ID = config.registryId; const client = new SuiGrpcClient({ network: "testnet", baseUrl: config.rpcUrl, }); const keypair = Ed25519Keypair.fromSecretKey(privateKey); const walrusWriter = DefaultWalrusWriteAdapter.fromConfig( { network: "testnet", suiClient: client }, keypair, ); // --------------------------------------------------------------------------- // 2. Create a publication // new_publication returns (Publication, OwnerCap, PublisherCap). // Share the publication and transfer the caps in the same PTB. // --------------------------------------------------------------------------- const pubTx = new Transaction(); const created = pubTx.moveCall({ target: `${PACKAGE_ID}::publication::new_publication`, arguments: [ pubTx.object(REGISTRY_ID), pubTx.pure.string("My Blog"), pubTx.pure.string(`my-blog-${Date.now()}`), // slugs are globally unique on-chain ], }); // Share the publication so other transactions can reference it. pubTx.moveCall({ target: `${PACKAGE_ID}::publication::share_publication`, arguments: [created[0]], }); // Transfer caps using typed wrappers (key-only objects can't use transferObjects). pubTx.moveCall({ target: `${PACKAGE_ID}::publication::transfer_owner_cap`, arguments: [created[1], pubTx.pure.address(keypair.toSuiAddress())], }); pubTx.moveCall({ target: `${PACKAGE_ID}::publication::transfer_publisher_cap`, arguments: [created[2], pubTx.pure.address(keypair.toSuiAddress())], }); const pubResult = await client.signAndExecuteTransaction({ transaction: pubTx, signer: keypair, include: { effects: true, objectTypes: true }, }); // Extract created object IDs from transaction effects. const { effects, objectTypes } = pubResult.Transaction; const createdObjects = effects.changedObjects .filter((c) => c.idOperation === "Created" && objectTypes?.[c.objectId]) .map((c) => ({ objectId: c.objectId, type: objectTypes[c.objectId] })); const PUBLICATION_ID = createdObjects.find((o) => o.type.includes("Publication"))!.objectId; const OWNER_CAP_ID = createdObjects.find((o) => o.type.includes("OwnerCap"))!.objectId; const PUBLISHER_CAP_ID = createdObjects.find((o) => o.type.includes("PublisherCap"))!.objectId; console.log("Publication:", PUBLICATION_ID); console.log("OwnerCap:", OWNER_CAP_ID); console.log("PublisherCap:", PUBLISHER_CAP_ID); // Wait for the transaction to be indexed before referencing the new objects. await client.waitForTransaction({ digest: pubResult.Transaction.digest }); // --------------------------------------------------------------------------- // 3. Add a collection // Blob mode (storage_mode = 0): each entry holds a standalone Walrus Blob. // --------------------------------------------------------------------------- const colTx = new Transaction(); colTx.moveCall({ target: `${PACKAGE_ID}::publication::create_collection`, arguments: [ colTx.object(PUBLICATION_ID), colTx.object(PUBLISHER_CAP_ID), colTx.pure.string("posts"), colTx.pure.u8(0), // STORAGE_MODE_BLOB ], }); const colResult = await client.signAndExecuteTransaction({ transaction: colTx, signer: keypair }); console.log("Collection created: posts"); // Wait for the collection transaction to be indexed before modifying the publication again. await client.waitForTransaction({ digest: colResult.Transaction.digest }); // --------------------------------------------------------------------------- // 4. Upload content to Walrus and add an entry // Upload returns the Blob *object* ID (a Sui object reference). // Pass it via tx.object() - the contract takes &Blob, not the hash. // --------------------------------------------------------------------------- const content = new TextEncoder().encode( JSON.stringify({ title: "Hello World", body: "My first post on Morse." }) ); const { blobObjectId: BLOB_OBJECT_ID } = await walrusWriter.uploadBlob(content, { epochs: 3, deletable: true, }); console.log("Blob uploaded:", BLOB_OBJECT_ID); // BCS-encode Option>::None for quilt_patch_id and seal_id. const optionNone = bcs.option(bcs.vector(bcs.u8())).serialize(null).toBytes(); const entryTx = new Transaction(); entryTx.moveCall({ target: `${PACKAGE_ID}::publication::add_entry_to_collection`, arguments: [ entryTx.object(PUBLICATION_ID), // &mut Publication entryTx.object(PUBLISHER_CAP_ID), // &PublisherCap entryTx.pure.string("posts"), // collection name entryTx.pure.string("hello-world"), // entry name entryTx.object(BLOB_OBJECT_ID), // blob: &Blob (must be deletable) entryTx.pure(optionNone), // quilt_patch_id: None (blob mode) entryTx.pure.string("application/json"), // content type entryTx.pure.bool(false), // encrypted: false entryTx.pure.u8(0), // access_policy: PUBLIC entryTx.pure(optionNone), // seal_id: None ], }); await client.signAndExecuteTransaction({ transaction: entryTx, signer: keypair }); console.log("Entry added: hello-world (ID 0)"); ``` > **TIP: Which IDs to save** > > Save the Publication, OwnerCap, and PublisherCap object IDs from the output; you need them for all subsequent contract calls. You can also recover the publication ID from its slug using `get_publication_id_by_slug` on the registry. ## Running it ```bash PRIVATE_KEY="suiprivkey1..." bun run quickstart-contracts.ts ``` ```output Publication: 0x1a2b...3c4d OwnerCap: 0x5e6f...7a8b PublisherCap: 0x9c0d...1e2f Collection created: posts Blob uploaded: 0x4d5e...6f7a Entry added: hello-world (ID 0) ``` > **TIP: You are live on-chain** > > Your publication, collection, and entry are on Sui testnet. The entry content is stored on Walrus. For a friendlier reading experience, use the [Morse CLI](https://docs.morsecms.xyz/quick-start-cli.html): `morse entry read 0`. ## Next steps - [Contract Cookbooks](https://docs.morsecms.xyz/contracts-workflows.html): Revisions, drafts, encrypted content, delegating access, and teardown, all via raw PTBs. - [Contract Reference](https://docs.morsecms.xyz/contracts-reference.html): Full function signatures, argument types, error codes, and Move struct definitions. - [Core Concepts](https://docs.morsecms.xyz/core-concepts.html): Understand the data model: publications, collections, entries, and revisions. --- # Walrus Mainnet > Morse runs on Walrus Mainnet. This page covers what changes when you move from testnet: the deployment, the risks, setup, uploads and encrypted content. > **WARNING: Read this before you switch** > > Gas and Walrus storage cost **real SUI and WAL**, and there is **no faucet**. **Deletions can't be undone.** Test every flow on testnet first, then repeat it on Walrus Mainnet. ## Status Morse has been live on Walrus Mainnet since 2026-09-09. The CLI and SDK ship with the mainnet addresses built in, so there are no addresses to supply. These are the current releases: | Package | Current release | | --- | --- | | [`@arcadiasystems/morse-sdk`](https://docs.morsecms.xyz/sdk-overview.html) | 0.11.0 | | [`@arcadiasystems/morse-cli`](https://docs.morsecms.xyz/cli-overview.html) | 0.13.0 | | [`@arcadiasystems/morse-uploader`](https://docs.morsecms.xyz/uploader.html) | 0.4.2 | An app that uses morse-uploader 0.4.2 stays on morse-sdk 0.10.1, because the uploader does not accept morse-sdk 0.11. See [React Uploader → Install](https://docs.morsecms.xyz/uploader.html#install). The default storage-node read path for `morse entry read` and `morse entry decrypt` works from morse-cli 0.11.0 and morse-sdk 0.9.0. Upgrade if you are on an earlier release. Public publications, collections, entries and files work on both networks. Encrypted content needs your own Seal credentials on mainnet. See [Encrypted content](#encrypted-content). ## The deployment Mainnet runs version 1 of the Morse Move package. It has never been upgraded, so `published-at` and `original-id` are the same address. | Field | Value | | --- | --- | | Package (`published-at`) | [`0x4fc7af5d1e19f96e5fab4e677948214eec35e238b252a106c7d5036dcebdcae2`](https://suiscan.xyz/mainnet/object/0x4fc7af5d1e19f96e5fab4e677948214eec35e238b252a106c7d5036dcebdcae2) | | `original-id` | `0x4fc7af5d1e19f96e5fab4e677948214eec35e238b252a106c7d5036dcebdcae2` | | `PublicationRegistry` | `0x8d8b23c7acd7c1b260c793f2c648c5be8eb06db7c107b9f06ca3f39b700868ea` | | Version | 1 | | Published | 2026-09-09 | You only need these ids for raw contract calls. Use `published-at` as the target of Move calls, and `original-id` for type-filtered queries. The source of truth is [`Published.toml`](https://github.com/arcadiasystems/morse-dcms/blob/main/morse-contracts/Published.toml). To build those calls yourself, see [Quick Start: Smart Contracts](https://docs.morsecms.xyz/quick-start-contracts.html). ## Setting up Fund the address you will use with SUI for gas and WAL for Walrus storage. There is no faucet on mainnet, so you have to acquire both. Nothing carries over from testnet. Mainnet has its own package and its own `PublicationRegistry`, so you create your publications and collections again. ### CLI Keep mainnet in its own profile, so your testnet profile stays as it is. Create the profile, make it the default, import the key that pays on mainnet, and check the result: ```bash morse config add mainnet --network mainnet morse config use mainnet morse account import morse status ``` `account import` prompts for the key and a password. If the key is already in your keystore, run `morse account use
` instead. Nothing selects mainnet for you. With no `--network`, `MORSE_NETWORK` or profile setting, the CLI targets testnet. Every command that spends gas prints the network it used, and `--json` output has a `network` field. To go back to the testnet profile from the quick start, run `morse config use testnet`. ### SDK `morseConfig({ network: "mainnet" })` returns the full mainnet config, with the contract addresses built in. Use the same network for the gRPC client and the Walrus adapters. Start from the [server setup](https://docs.morsecms.xyz/sdk-client-setup.html#server-setup) and change every network literal to `"mainnet"`: ```typescript const config = morseConfig({ network: "mainnet" }); const client = new SuiGrpcClient({ network: "mainnet", baseUrl: config.rpcUrl, }); const walrus = DefaultWalrusWriteAdapter.fromConfig( { network: "mainnet", suiClient: client }, keypair, ); ``` ## Uploads and the upload relay A direct upload sends slivers to every storage node in the Walrus committee at once: about 95 on mainnet. Many networks can't sustain that burst. The upload then fails with `NotEnoughBlobConfirmationsError` or "Unable to connect", even when the nodes are healthy. If uploads fail while reads work, this is almost certainly why. It is not a fault in your config. Retry through the upload relay, which takes one connection instead of about 95. `--upload-relay` is a global option, so it goes before the subcommand: ```bash morse --upload-relay auto entry add hello --file post.json ``` `auto` picks Mysten's canonical relay for the network. To use the relay for every upload, save it on the profile. Running `config add` on an existing profile updates it and keeps its other settings: ```bash morse config add mainnet --network mainnet --upload-relay auto ``` Three things to know before you use it: - **It charges a small tip per upload**, on top of WAL and gas. - **The tip is capped.** `--max-tip` sets the cap in MIST and defaults to 10000000 (0.01 SUI). Over the cap, the upload is refused before anything is spent. - **The relay sees your bytes.** Encrypt first if that matters. The relay is opt-in. The direct path is free and trustless, and most networks handle it fine. In the SDK, pass `uploadRelay` with a `host` and a `sendTip.max` cap to `DefaultWalrusWriteAdapter.fromConfig`. For browser apps, see [React Uploader → Walrus upload relay](https://docs.morsecms.xyz/uploader.html#upload-relay). The CLI README covers the relay in [When uploads fail: the upload relay](https://github.com/arcadiasystems/morse-dcms/blob/main/morse-cli/README.md#when-uploads-fail-the-upload-relay). Storage is paid per epoch. A testnet epoch is roughly a day, and mainnet epochs are much longer, so the same `--epochs` value buys a very different duration. ## Encrypted content Encrypted commands need Seal key servers. Testnet pins an open set, so they work there with no setup. Walrus Mainnet pins none, because every Seal operator there is commercial. You bring your own credential. Without one, the CLI's encrypted commands exit with code 2 and an explanation. In the CLI, set one of these environment variables: | Variable | Use | | --- | --- | | `MORSE_SEAL_API_KEY` | Credential for Mysten's Seal committee. The CLI already knows the committee's object id and aggregator. | | `MORSE_SEAL_API_KEY_NAME` | Header name for that credential. Defaults to `X-API-Key`. | | `MORSE_SEAL_KEY_SERVERS` | JSON array of `{ objectId, weight }` for an operator that is not the committee. Wins over `MORSE_SEAL_API_KEY`. | They are environment variables, not flags, so the credential stays out of `ps` and shell history. Before it encrypts anything, the CLI checks the credential and refuses if the operator answers `401` or `403`. Without that check, a bad credential would let encryption succeed and decryption fail, and you would pay to store content nobody can read. In the SDK, `DefaultSealAdapter.fromMorseConfig` throws `ConfigurationError` on mainnet until you supply key servers. `MAINNET_SEAL_COMMITTEE` is Mysten's committee in the right shape. Add the `apiKeyName` and `apiKey` that Mysten issues you, and pass the result to `morseConfig` as `sealKeyServers`. For browser apps, see [React Uploader → Encrypted files and Seal on Walrus Mainnet](https://docs.morsecms.xyz/uploader.html#seal-on-walrus-mainnet). Encryption has not been verified end to end on Walrus Mainnet, because that needs a commercial credential. The CLI README covers the setup in [Seal on mainnet](https://github.com/arcadiasystems/morse-dcms/blob/main/morse-cli/README.md#seal-on-mainnet). ## Next steps - [Quick Start: CLI](https://docs.morsecms.xyz/quick-start-cli.html): Run the whole flow on testnet from your terminal before you switch. - [Quick Start: SDK](https://docs.morsecms.xyz/quick-start-sdk.html): Run the same flow on testnet as a TypeScript script. - [CLI: Command Reference](https://docs.morsecms.xyz/cli-commands.html): Every command and flag, including profiles and the global options. - [SDK: Setup](https://docs.morsecms.xyz/sdk-client-setup.html): Install, wire a wallet, pick a Walrus adapter, handle errors. --- # Cookbooks > Eight things people actually want to do with Morse, each as a short path with real commands and the failure modes you will hit on testnet. > **INFO: How these differ from the reference cookbooks** > > The [CLI](https://docs.morsecms.xyz/cli-workflows.html), [SDK](https://docs.morsecms.xyz/sdk-workflows.html) and [contracts](https://docs.morsecms.xyz/contracts-workflows.html) cookbooks show every operation once. These recipes start from a goal, take ten to twenty minutes end to end, and say what to do when a step fails on testnet. Every recipe assumes a funded Sui testnet wallet. If you do not have one yet, start with the first recipe, it walks through the faucet. ## Publish Get content on chain and keep it right. - [Launch a blog in 10 minutes](https://docs.morsecms.xyz/launch-a-blog.html): Publication, collection and a first post on Sui testnet, nothing else. *(10 min · CLI)* - [Fix a typo without losing history](https://docs.morsecms.xyz/fix-a-typo.html): Publish a corrected revision and keep every earlier one readable. *(5 min · CLI)* - [Import an existing Markdown site](https://docs.morsecms.xyz/import-a-site.html): A folder of Markdown posts in, one entry per file, re-runnable. *(15 min · CLI + bash)* ## Collaborate Other people, other wallets, same publication. - [Add a co-author](https://docs.morsecms.xyz/co-author.html): Issue a PublisherCap to another wallet, see who can write, revoke. *(10 min · CLI)* - [Encrypt a post for your team](https://docs.morsecms.xyz/team-only-content.html): Seal-encrypt a post so only cap holders can decrypt it. *(10 min · CLI)* ## Automate Pipelines and agents as publishers. - [Publish from CI](https://docs.morsecms.xyz/publish-from-ci.html): Push to main, get a revision on chain, with a key you can revoke. *(20 min · CLI + GitHub Actions)* - [Let an AI agent publish](https://docs.morsecms.xyz/agent-publishes.html): A wallet, a cap and llms.txt, and an agent publishes unattended. *(15 min · CLI or SDK)* ## Trust What a reader can check without asking you. - [Prove a post has not changed](https://docs.morsecms.xyz/verify-a-post.html): Check bytes, revision and author of any post from a clean machine. *(10 min · CLI, no key)* ## Conventions | You see | It means | | --- | --- | | `$ command` | Type it in a shell. The Copy button strips the prompt. | | `0x69d4...f327` | A shortened id. Yours will differ, never copy these. | | `--via-aggregator` | Read through the public Walrus aggregator. Reliable on testnet, trusts the operator. | | `--epochs 14` | How long Walrus keeps the bytes. One epoch is about a day on testnet. | | Active context | The publication and collection `morse status` shows. Set with `morse use`. | > **WARNING: Testnet first** > > Morse runs on Walrus Mainnet and testnet. These recipes use testnet: it resets, blobs expire, tokens are free and worthless. Treat everything you publish as a rehearsal. Mainnet spends real SUI and WAL, so get a recipe working here first. --- # Launch a blog in 10 minutes > Zero to a post whose bytes are on Walrus and whose record is on Sui, with nothing but your terminal. About ten minutes, most of it waiting for a faucet. - About 10 minutes - CLI only - Fresh Sui wallet - Testnet > **WARNING: Runs on testnet** > > Everything here runs on Sui testnet and Walrus testnet. Testnets reset from time to time, and blobs expire when their storage epochs run out. Testnet tokens have no value. Morse also runs on Walrus Mainnet, where the same steps cost real SUI and WAL, so get them working here first. Do not publish anything you cannot afford to lose. ## What you will have A publication on Sui that you own, one collection called `posts`, and one post whose body lives on Walrus and whose record lives on Sui. Nothing else: no server, no database, no account with us. | You end up with | Where it lives | How you prove it | | --- | --- | --- | | A Publication object | Sui testnet | Its object id, shown by `morse publication get` | | A `posts` collection | Sui testnet, inside the publication | `morse collection list` | | Entry `0` with one public revision | Record on Sui, bytes on Walrus | `morse entry get 0` and the blob id | ## Get a wallet and testnet tokens You need a Sui address with a little SUI for gas and a little WAL for storage. Both are free on testnet and take about two minutes. | Step | Where | Why | | --- | --- | --- | | Install a wallet | Slush, Suiet, or any Sui Wallet Standard wallet | Holds your key and signs | | Get testnet SUI | [faucet.sui.io](https://faucet.sui.io/) | Gas for every on-chain action | | Swap a little SUI for WAL | [stake-wal.wal.app](https://stake-wal.wal.app/?network=testnet) | Walrus storage fees when you publish | | Export your private key | Wallet settings | A `suiprivkey1...` string the CLI can import | > **INFO: Use a throwaway key** > > Create a fresh wallet for Morse testnet. The CLI stores the key in an encrypted keystore under `~/.config/morse/keystores`, but a key you only use for testnet is one less thing to worry about. ## Install the CLI ```bash $ npm i -g @arcadiasystems/morse-cli $ morse --version ``` If you would rather not install anything, every command below also works as `npx @arcadiasystems/morse-cli ...`. ## Point it at testnet and import your key ```bash $ morse config add testnet --network testnet $ morse account import ``` `account import` asks for the `suiprivkey1...` string and a password, both hidden. Check that it took: ```bash $ morse status ``` ```output profile: testnet network: testnet account: 0x7be4...11d0 publication: (none) collection: (none) ``` ## Create the publication Slugs are globally unique on the network, so add a suffix. The name and slug cannot be changed later, pick ones you like. ```bash $ morse publication create --name "My Blog" --slug "my-blog-$(date +%s)" $ morse collection create posts ``` Both commands set what they created as your active context, so you do not have to pass `-P` or `-C` on every call. Two transactions have been signed with your key: one created the publication and minted your `OwnerCap` and `PublisherCap`, the other added the collection. ## Write and publish the first post A post is just a file. Markdown is a good default because every reader can render it. ```bash $ cat > hello.md <<'EOF' # Hello from Morse This post has no backend. Its bytes are on Walrus, its record is on Sui. EOF $ morse entry add hello --file hello.md --epochs 14 ``` `--epochs` is how long Walrus keeps the blob. The default is 3, which is about three days on testnet. Fourteen is enough for a demo you want to show people next week. The content type is inferred from the file extension. ```output Uploading 93 bytes to Walrus... Added entry #0 "hello". (tx: D9YfjkA3...9vB) network: testnet view: https://aggregator.walrus-testnet.walrus.space/v1/blobs/0iKeiiQ2...m7Xp ``` > **INFO: Two transactions, one command** > > `entry add` uploads the bytes to Walrus and registers the blob, then records the entry on Sui. If the second step fails you will see an error naming the blob that was already uploaded; rerun the command, the blob is not lost. ## Read it back ```bash $ morse entry read 0 ``` The CLI reads the post from the Walrus storage nodes and checks the bytes against the blob id recorded on Sui. ## Share the record This is the part a hosted CMS cannot do. Print the record and hand it to anyone: ```bash $ morse --json entry get 0 | jq . ``` The output includes the public head and the revision list, and for each revision the address that wrote it and the Sui object id of its Walrus blob. Paste your publication id (`morse status` shows it) into [suiscan.xyz/testnet](https://suiscan.xyz/testnet/home) and anyone can see who published to it and when, without a Morse account, without trusting you. > **TIP: You are live** > > Your publication, collection and first post exist on Sui testnet. Run `morse entry list` to see them, or go straight to [Fix a typo without losing history](https://docs.morsecms.xyz/fix-a-typo.html) for the second thing every blog needs. ## If something fails | Symptom | Likely cause | Fix | | --- | --- | --- | | `exit 2` and "No account selected" on `publication create` | No active account, or wrong profile | `morse status`, then `morse account use
` | | Insufficient gas / balance | No SUI or no WAL on the address | Faucet for SUI, swap for WAL, check the address matches `morse status` | | `ESlugAlreadyExists` | Someone used that slug | Add a suffix, the `$(date +%s)` trick above does this | | `entry read` exits 3 and says no valid blob metadata | A bug in the verified read in morse-cli 0.10.1, not a timing issue | Upgrade to morse-cli 0.11.0; waiting does not help | | `entry read` exits 5 and says to retry or pass `--via-aggregator` | The storage nodes did not return the content; the blob may be healthy | Retry, or add `--via-aggregator` to read through a Walrus aggregator | | Post disappeared after a while | Blob epochs expired or testnet reset | Republish with a larger `--epochs`; the entry record on Sui is unaffected | ## Next - [Fix a typo without losing history](https://docs.morsecms.xyz/fix-a-typo.html): Publish a second revision and keep the first readable. - [Import an existing Markdown site](https://docs.morsecms.xyz/import-a-site.html): Loop over a folder of posts and publish them all. - [Prove a post has not changed](https://docs.morsecms.xyz/verify-a-post.html): Check a post against its Sui record and Walrus blob. --- # Fix a typo without losing history > Correct a published post without rewriting history. Two ways to do it, and how to read any revision back. - About 5 minutes - CLI - An existing entry - Testnet ## Why revisions, not edits Morse never overwrites a post. Every change is a new revision appended to the entry, and every earlier revision stays readable forever. An entry keeps two pointers, the **public head** (what readers get) and the **draft head** (what you are working on). Fixing a typo moves the public head, it does not erase anything. ```text entry 0 revision 0 public "Hello from Morse" <- still readable revision 1 public "Hello from Morse" (fixed) <- public head ``` Everything below assumes you have the publication and collection from [Launch a blog](https://docs.morsecms.xyz/launch-a-blog.html) set as your active context. Check with `morse status`. ## Publish a corrected revision in one step Edit the file, then publish it straight to the public head: ```bash $ sed -i.bak 's/no backend/no backend at all/' hello.md $ morse revision publish-direct 0 --file hello.md --epochs 14 ``` ```output Uploading 100 bytes to Walrus... Blob uploaded (0xa3d0...2c1f). Submitting transaction... Published revision #1 on entry #0. (tx: 7Kq2mRv1...9v8) network: testnet ``` One upload, one transaction. The old blob and the old revision are untouched. ## Or stage a draft first For anything bigger than a typo, stage the change as a draft, read it back, and only then publish it. Drafts are real revisions on chain, they are just not the public head. ```bash $ morse revision append-draft 0 --file hello.md --epochs 14 $ morse entry list --drafts-only $ morse revision publish-from-draft 0 2 ``` The second argument of `publish-from-draft` is the draft revision's index. `morse entry get 0` shows it as `draftHead`; `entry list --drafts-only` only tells you which entries have a pending draft. The `2` above assumes you also ran the one-step publish, which made revision 1; on a fresh entry the draft would be revision 1. Publishing does not promote the draft in place: it appends a new public revision that reuses the draft's blob, so here the entry ends with revisions 0 to 3 and revision 3 is the public head. Pass `--file` if you want to replace the content at the moment of publishing. > **INFO: Encrypted entries always start as drafts** > > An entry added with `entry add-encrypted` starts as a draft and stays one: published revisions are always public and unencrypted, so team-only content is read from the draft with `entry decrypt`. See [Encrypt a post for your team](https://docs.morsecms.xyz/team-only-content.html). ## Read any earlier revision `entry read` takes an optional revision index. Without it you get the public head. ```bash $ morse entry read 0 # public head $ morse entry read 0 0 # revision 0, the original $ morse entry read 0 0 --out original.md $ diff original.md hello.md ``` That diff is the whole point. Anyone with the entry id can produce it, with no help from you. ## What readers see A reader app that follows the public head sees the corrected post immediately. A reader who cares can fetch the entry record from Sui and walk the revision list, each revision pointing at its own Walrus blob and naming the address that wrote it. The demo blog shows this as a Revisions panel next to the post. | Operation | Command | Moves public head | Keeps history | | --- | --- | --- | --- | | Publish corrected text now | `revision publish-direct` | Yes | Yes | | Stage, review, then publish | `revision append-draft` + `publish-from-draft` | On publish | Yes | | Delete the entry | `entry delete` | n/a | No, and the id is retired for good | > **WARNING: Blobs expire, records do not** > > Each revision's bytes live on Walrus for the epochs you paid for. When they expire, the revision record on Sui still exists but the content cannot be fetched. Use a generous `--epochs` for anything you want readable next month, and expect testnet resets. ## Next - [Prove a post has not changed](https://docs.morsecms.xyz/verify-a-post.html): Compare what a reader got with the chain record. - [Add a co-author](https://docs.morsecms.xyz/co-author.html): Let someone else publish revisions to your blog. - [CLI command reference](https://docs.morsecms.xyz/cli-commands.html): Every revision and entry flag. --- # Import an existing Markdown site > Bring an existing Hugo, Astro or plain Markdown site into Morse. One entry per post, a map file so you can run it twice, and a way to push edits as revisions. - About 15 minutes - CLI + bash + jq - A folder of .md files - Testnet ## What this does You have a folder of Markdown posts from Hugo, Astro, Jekyll, Obsidian or a plain repo. This recipe publishes each file as one Morse entry, writes a small map from filename to entry id, and lets you run it again later without duplicating anything. Morse stores bytes as they are, so front matter goes in untouched and your existing renderer keeps working. ## Lay out the folder ```text content/ posts/ 2026-09-01-why-a-protocol.md 2026-09-14-dual-head-revisions.md 2026-10-02-publishing-from-an-agent.md morse-map.json <- created by the script ``` Make sure your active context is the publication and collection you want to fill: ```bash $ morse use my-blog-1760000000 posts $ morse status ``` ## Dry run Count the files and estimate storage before spending WAL. Walrus charges per blob per epoch, so the total size and the epoch count are what matter. ```bash $ ls content/posts/*.md | wc -l $ du -ch content/posts/*.md | tail -1 ``` ## Publish everything The script publishes every file that is not yet in the map. The entry name is the filename without the extension. `--json` gives machine-readable output, `-q` hides the progress lines, and `jq` pulls the entry id out. ```bash #!/usr/bin/env bash set -euo pipefail MAP=morse-map.json EPOCHS=${EPOCHS:-30} [ -f "$MAP" ] || echo '{}' > "$MAP" for f in content/posts/*.md; do name=$(basename "$f" .md) if jq -e --arg n "$name" '.[$n]' "$MAP" >/dev/null; then echo "skip $name (already published)"; continue fi echo "add $name" out=$(morse --json -q entry add "$name" --file "$f" --epochs "$EPOCHS") id=$(echo "$out" | jq -r '.entryId') tmp=$(mktemp) jq --arg n "$name" --arg id "$id" '.[$n] = {entryId: ($id|tonumber), file: $n}' "$MAP" > "$tmp" && mv "$tmp" "$MAP" done echo "done, $(jq length "$MAP") entries in $MAP" ``` ```bash $ chmod +x import.sh $ EPOCHS=30 ./import.sh ``` > **INFO: One transaction pair per post** > > Each `entry add` signs two transactions. Thirty posts is sixty signatures and about a minute on testnet. If a run stops halfway, run it again, the map makes it pick up where it left off. ## Keep the map Commit `morse-map.json` next to your content. It is the only thing that ties a filename to an entry id, and your site generator or reader app needs it to fetch bodies from Walrus. ```json { "2026-09-01-why-a-protocol": { "entryId": 0, "file": "2026-09-01-why-a-protocol" }, "2026-09-14-dual-head-revisions": { "entryId": 1, "file": "2026-09-14-dual-head-revisions" } } ``` ## Re-running safely New files get added, existing ones are skipped. To push an edited post as a new revision rather than a new entry, look the id up in the map and use `revision publish-direct`: ```bash $ id=$(jq -r '.["2026-09-14-dual-head-revisions"].entryId' morse-map.json) $ morse revision publish-direct "$id" --file content/posts/2026-09-14-dual-head-revisions.md --epochs 30 ``` A simple way to automate that: store each file's content hash in the map and publish a revision whenever the hash changes. Left as a ten-line exercise, or ask your agent, see [Let an AI agent publish](https://docs.morsecms.xyz/agent-publishes.html). ## Pitfalls | Pitfall | What happens | What to do | | --- | --- | --- | | Entry names are not unique on chain | Running without the map creates duplicates | Always keep and commit the map | | Images and other assets | A Markdown file only carries text | Publish each asset as its own entry, or keep assets on a plain host for now | | Big files | Walrus storage cost scales with size and epochs | Keep posts as Markdown, strip embedded base64 | | Testnet reset | Entries and blobs are gone, the map is stale | Delete the map and re-run | ## Next - [Publish from CI](https://docs.morsecms.xyz/publish-from-ci.html): Run this script on every push to main. - [Fix a typo without losing history](https://docs.morsecms.xyz/fix-a-typo.html): How revisions work. - [SDK reference cookbooks](https://docs.morsecms.xyz/sdk-workflows.html): The same loop in TypeScript with addEntryFromBytes. --- # Add a co-author > Let a second person publish to your blog without sharing a key, see who can write at any time, and revoke in one command. - About 10 minutes - CLI, two machines - Owner plus one more wallet - Testnet ## How write access works There are no user accounts in Morse. Write access is an object. When you created your publication you received an `OwnerCap` (full control) and a `PublisherCap` (can add entries and revisions). To let someone else publish, the owner mints a new `PublisherCap` bound to their address. To take it away, the owner revokes it. The co-author never needs your key, and you never need theirs. | Capability | Who holds it | Can do | | --- | --- | --- | | `OwnerCap` | You, one per publication | Issue and revoke publisher caps, transfer ownership, delete | | `PublisherCap` | You and anyone you issue one to | Create collections, add entries, append and publish revisions, decrypt team content | | Nothing | Everyone else | Read public entries | ## Owner: issue a cap You need the co-author's Sui address. From your machine, with the publication active: ```bash $ morse status # confirm the active publication $ morse cap issue 0xCOAUTHOR_ADDRESS ``` ```output Resolving OwnerCap... Issued PublisherCap 0x3f8a...c21e to 0xCOAUTHOR_ADDRESS. (tx: 4Hn9xT...q2Wd) network: testnet ``` Save the cap id, you will need it to revoke. The cap is sent to the co-author's address as part of the same transaction, there is nothing for you to hand over. ## Co-author: set up and publish On the co-author's machine, same setup as [Launch a blog](https://docs.morsecms.xyz/launch-a-blog.html), then point at your publication by slug: ```bash $ npm i -g @arcadiasystems/morse-cli $ morse config add testnet --network testnet $ morse account import $ morse cap list # the new cap shows up here $ morse use my-blog-1760000000 posts $ morse entry add guest-post --file guest-post.md --epochs 14 ``` The CLI finds the right `PublisherCap` automatically from the active account. If the co-author holds caps for several publications, `--publisher-cap ` picks one explicitly. > **INFO: Reads by slug need an account, reads by id do not** > > `morse use ` resolves the slug through the active account. Anyone can read a publication by object id with no key at all: `morse publication get 0x69d4...f327`. ## Owner: see who can write ```bash $ morse cap list 0xCOAUTHOR_ADDRESS ``` There is no team page, and anyone can run this query on chain. It lists every PublisherCap the address holds, revoked ones included. Since morse-cli 0.12.0, a cap that can no longer write is marked `[revoked]`, or `[publication deleted]` when its publication is gone, and `--json` gives each cap a `status` of `active`, `revoked` or `publication-deleted`. ## Revoke, and what the co-author sees ```bash $ morse cap revoke 0x3f8a...c21e ``` The CLI asks you to confirm before it revokes. In a script, put the global `--yes` before the subcommand: `morse --yes cap revoke 0x3f8a...c21e`. Without it a non-interactive shell refuses with exit 2. From the next transaction on, every write the co-author attempts with that cap aborts on chain. `revision publish-from-draft`, for example, exits with code 4 and says the cap has been revoked. `entry add` and `entry add-encrypted` are the exception: they upload to Walrus first, so the upload is paid for, and then exit with code 1 and an "uploaded but the on-chain transaction failed" message that does not mention the revocation. Everything they published before stays. A revoked cap still sits in their wallet as a dead object; they can tidy it with `morse cap destroy 0x3f8a...c21e`. ## Rotate instead of transfer > **WARNING: Do not transfer caps between people** > > A cap's holder is fixed when it is issued. `cap transfer` moves the object but not the binding, so the recipient cannot use it and the original holder cannot either. To hand write access to a new person: owner revokes the old cap, owner issues a new one. ```bash $ morse cap revoke 0xOLD_CAP_ID $ morse cap issue 0xNEW_PERSON_ADDRESS ``` Transferring the whole publication is different and final: `morse publication transfer-ownership 0xNEW_OWNER` hands over the `OwnerCap` with no undo. Existing publisher caps keep working until the new owner revokes them. ## Next - [Encrypt a post for your team](https://docs.morsecms.xyz/team-only-content.html): Cap holders can also decrypt. - [Let an AI agent publish](https://docs.morsecms.xyz/agent-publishes.html): An agent is just another cap holder. - [Access control reference](https://docs.morsecms.xyz/access-control.html): Caps, policies and the Move checks behind them. --- # Encrypt a post for your team > Client-side encryption with the access rule living on Sui. Who can read is who holds a live cap, and revoking is one command. - About 10 minutes - CLI - A publication with a blob collection - Testnet ## What encryption gives you today Morse encrypts content client side with [Seal](https://seal-docs.wal.app/) before it goes to Walrus. The decryption rule is a Move function on Sui: a Seal key server only releases its share if the caller holds an active `PublisherCap` for that publication. So today, encrypted means **readable by your team**: everyone with write access, nobody else, and no server of yours in the middle. | Policy | Who can read | Status | | --- | --- | --- | | Public (0) | Anyone with the blob id | Live | | Publisher (1) | Active `PublisherCap` holders of the publication | Live, this recipe | | Subscription (2) | Holders of a subscription token | Reserved on chain, approval function not built yet | ## Publish an encrypted entry With your publication and collection active: ```bash $ cat > q4-plan.md <<'EOF' # Q4 plan (internal) Ship mainnet, then the indexer. EOF $ morse entry add-encrypted q4-plan --file q4-plan.md --epochs 30 ``` ```output Encrypting and uploading 53 bytes... Added encrypted entry #5 "q4-plan". (tx: 8Bq1mVr0...2kLs) network: testnet ``` Three things happened: the CLI generated a fresh Seal identity scoped to your publication, encrypted the file locally, and uploaded only ciphertext. The entry was created as a **draft**. Encrypted entries always start as drafts, because publishing is a separate decision. > **INFO: Collection mode** > > Encrypted entries need a `blob` mode collection, which is the default. If you created the collection with `--mode quilt`, make another one for encrypted content. ## Read it as a cap holder Reading is `entry decrypt`, not `entry read`. The CLI signs a short-lived session key with your account, asks the key servers for shares, and decrypts locally. ```bash $ morse entry decrypt 5 --out q4-plan.recovered.md $ diff q4-plan.md q4-plan.recovered.md && echo identical ``` Anyone you issued a cap to in [Add a co-author](https://docs.morsecms.xyz/co-author.html) can run the same command from their own wallet. Anyone else gets the ciphertext from Walrus and nothing from the key servers. ## Release it to everyone A draft is visible as an entry on chain but is not the public head, and for encrypted content it stays that way: published revisions are always public and unencrypted, so the team keeps reading the draft with `entry decrypt`. The CLI will not add a public revision to an encrypted entry. Both of these exit with code 2 and "Encrypted revisions are not yet supported": ```bash $ morse revision publish-from-draft 5 0 --file q4-plan.md # refused $ morse revision publish-direct 5 --file q4-plan.md # refused ``` To release the text, publish the plaintext as a new, ordinary entry with `morse entry add q4-plan-public --file q4-plan.md --epochs 30`. The encrypted entry stays a team-only draft. ## Rotate a key, revoke a reader Access is enforced at decryption time, every time. Revoking a cap cuts off a reader even for content they could read yesterday: ```bash $ morse cap revoke 0xTHEIR_CAP_ID ``` Note what this does and does not do. It stops the key servers from serving that wallet from now on. It does not un-read anything they already decrypted and saved. Same as any system, but worth saying out loud to a team. ## Honest limits | Question | Answer today | | --- | --- | | Can I sell access to a post? | Not yet. The subscription policy exists on chain but its approval function is not built. It is on the roadmap. | | Can a reader get read access without write access? | Not with Seal gating. A `PublisherCap` is both. Keep the team small. | | Who runs the key servers? | Seal key servers on testnet, two of three shares needed by default. If they are down, nobody can decrypt, including you. | | Is the ciphertext safe on Walrus? | Walrus stores the bytes anyone can fetch. The key never leaves the key servers and your client. | ## Next - [Add a co-author](https://docs.morsecms.xyz/co-author.html): Issue the caps your readers need. - [Fix a typo without losing history](https://docs.morsecms.xyz/fix-a-typo.html): Drafts and heads explained. - [Access control reference](https://docs.morsecms.xyz/access-control.html): seal_approve_publisher and friends. --- # Publish from CI > Push to main, get a revision on chain. A separate pipeline key you can revoke, the environment variables the CLI reads, and a workflow file to copy. - About 20 minutes - CLI + GitHub Actions - A repo of posts - Testnet ## The idea Your posts live in a git repo. On every push to `main`, CI publishes new files as entries and changed files as revisions. No deploy step, no server to keep up: the published state is on Sui and Walrus, the source of truth is the repo, and the map file links the two. ## A CI-only publisher key Do not put your owner key in CI. Make a separate wallet for the pipeline, fund it with a little SUI and WAL, and issue it a `PublisherCap` from your owner wallet. If CI ever leaks the key, you revoke one cap and move on. ```bash $ morse cap issue 0xCI_WALLET_ADDRESS $ morse --json publication get | jq -r .id # you will need this id ``` ## Environment variables the CLI reads In a non-interactive shell the CLI takes everything from the environment. Flags beat variables, variables beat the config file. | Variable | Set it to | Why | | --- | --- | --- | | `MORSE_PRIVATE_KEY` | The CI wallet's `suiprivkey1...` | Highest-priority key source, never written to disk | | `MORSE_NETWORK` | `testnet` | No profile needed | | `MORSE_PUBLICATION` | The publication object id | Active publication without `morse use` | | `MORSE_COLLECTION` | `posts` | Active collection | With those four set, `morse status` in CI shows the same context you have locally, with no keystore and no prompts. ## GitHub Actions workflow This reuses `import.sh` from [Import an existing Markdown site](https://docs.morsecms.xyz/import-a-site.html) and commits the updated map back to the repo so the next run knows what exists. ```yaml name: Publish to Morse on: push: branches: [main] paths: ["content/posts/**", "morse-map.json"] permissions: contents: write jobs: publish: runs-on: ubuntu-latest env: MORSE_PRIVATE_KEY: ${{ secrets.MORSE_CI_PRIVATE_KEY }} MORSE_NETWORK: testnet MORSE_PUBLICATION: ${{ vars.MORSE_PUBLICATION_ID }} MORSE_COLLECTION: posts EPOCHS: "30" steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: 20 } - run: npm i -g @arcadiasystems/morse-cli - run: morse --yes -q status - run: ./import.sh - name: Commit the map run: | git config user.name "morse-ci" git config user.email "ci@users.noreply.github.com" git add morse-map.json git diff --cached --quiet || git commit -m "chore: update morse map" && git push ``` Store the key as a repository **secret** and the publication id as a repository **variable**. The `paths` filter keeps the job from running on unrelated commits. ## Idempotency and exit codes The map is what makes a rerun safe: a file already in it is skipped. Everything else you need for scripting is in the CLI's exit codes and `--json`. | Exit code | Meaning | In a pipeline | | --- | --- | --- | | 0 | Success | Carry on | | 1 | Any other failure | For example a contract abort such as `ESlugAlreadyExists`, or a write that uploaded to Walrus and then failed on chain; read the message and fail the job | | 2 | Usage error | Your command line is wrong, fix the script | | 3 | Not found | Wrong publication id or entry id | | 4 | Auth or permission | Cap revoked, wrong key, or no cap for this publication. A revoked cap on `entry add` or `entry add-encrypted` exits 1 instead, after the upload | | 5 | Network or transport | Retry once after a pause, then fail the job | ```bash publish() { morse --json -q entry add "$@"; } out=$(publish "$name" --file "$f" --epochs "$EPOCHS") || { [ $? -eq 5 ] && sleep 20 && out=$(publish "$name" --file "$f" --epochs "$EPOCHS"); } ``` > **INFO: --yes and -q** > > `--yes` answers every confirmation, which matters for `delete` commands and `cap revoke`; it goes before the subcommand. `-q` keeps progress bars out of your logs so the JSON on stdout is the only thing the script sees. ## Lock it down | Risk | Mitigation | | --- | --- | | Key leaks from CI | It is a publisher key, not the owner. `morse cap revoke` and rotate. | | Someone pushes garbage to main | Branch protection, and the chain keeps every revision so nothing is lost | | Pipeline publishes twice | The map, committed back after every run | | WAL runs out | Check the CI wallet balance in the job and fail early with a clear message | ## Next - [Import an existing Markdown site](https://docs.morsecms.xyz/import-a-site.html): The script this workflow runs. - [Let an AI agent publish](https://docs.morsecms.xyz/agent-publishes.html): Same key model, different author. - [CLI overview](https://docs.morsecms.xyz/cli-overview.html): All environment variables and exit codes. --- # Let an AI agent publish > No signup, no API console. A wallet, a cap, the docs in llms.txt, and an agent can publish on its own while you keep the owner key. - About 15 minutes - CLI or SDK - An agent runtime with a shell - Testnet ## Why this works at all Most CMSs need a human to click through signup, create an API key and paste it somewhere. Morse has no signup. An agent with a funded Sui wallet and a `PublisherCap` can publish on its own, every write returns a transaction digest, and you can revoke it in one command without touching the agent. That makes Morse a natural output channel for an agent that writes: a changelog bot, a research digest, a status page, a newsletter drafted overnight. ## Give the agent its own wallet and cap Treat the agent like a contractor, not like yourself. Fresh wallet, small balance, its own cap. ```bash $ morse cap issue 0xAGENT_WALLET_ADDRESS $ morse --json publication get | jq -r .id ``` Fund the agent wallet from the faucet and swap a little WAL, as in [Launch a blog](https://docs.morsecms.xyz/launch-a-blog.html). Keep the owner key on your machine only. ## Give the agent the docs The docs ship in agent-readable form. Put this in the project's `CLAUDE.md`, `AGENTS.md` or Cursor rules so every session starts with Morse context: ```markdown # Morse This project publishes to a Morse publication on Sui testnet. Before using the Morse CLI or SDK, read https://docs.morsecms.xyz/llms-full.txt (or https://docs.morsecms.xyz/llms.txt for the index and fetch pages as .md). Rules - Use the CLI non-interactively: `morse --json -q --yes ...` - Never run `account export`, never print MORSE_PRIVATE_KEY. - New post: `morse entry add --file --epochs 30` - Edit an existing post: `morse revision publish-direct --file --epochs 30` - Look up entry ids in morse-map.json; update it after every add. - Read back with `morse entry read `. ``` ## Give the agent a job Environment for the agent's shell, same four variables as [Publish from CI](https://docs.morsecms.xyz/publish-from-ci.html): ```bash export MORSE_PRIVATE_KEY=suiprivkey1... # the agent wallet, not yours export MORSE_NETWORK=testnet export MORSE_PUBLICATION=0x69d4...f327 export MORSE_COLLECTION=posts ``` Then a prompt that describes the outcome, not the commands. The agent has the docs. ```text Every Friday, summarise the week's merged pull requests in this repo as a short Markdown post under content/posts/YYYY-MM-DD-weekly.md, then publish it to Morse as a new entry. Print the entry id and the Sui transaction digest when done. ``` ## What the agent runs Under the hood the agent ends up running something like this, which you can also run yourself to check the setup: ```bash $ morse --json -q --yes status $ morse --json -q --yes entry add 2026-10-17-weekly --file content/posts/2026-10-17-weekly.md --epochs 30 $ morse --json -q entry get 7 ``` For an agent built on the SDK rather than shelling out, the equivalent is `addEntryFromBytes` with a `KeypairAdapter` from the agent's key; the [SDK reference cookbooks](https://docs.morsecms.xyz/sdk-workflows.html) have the exact call. > **INFO: Why --json --yes -q** > > The agent should never sit at a prompt. `--yes` removes confirmations, `-q` removes progress output, `--json` gives it something to parse instead of guess. ## Watch, audit, revoke Every write the agent makes is a transaction signed by its wallet, so the audit log is Sui itself: ```bash $ morse entry list # what exists now $ morse cap list 0xAGENT_WALLET_ADDRESS # caps it holds, revoked ones included $ morse cap revoke 0xAGENT_CAP_ID # and when you are done ``` Revocation takes effect on the next transaction. Nothing the agent already published disappears, and if it published something wrong, you append a revision (or delete the entry) from your own wallet. | Do | Do not | | --- | --- | | Give the agent a wallet with days of budget, not months | Give it the owner key | | Point it at `llms-full.txt` | Paste commands you have not tested into its prompt | | Keep `morse-map.json` in the repo | Let it guess entry ids | | Review the first few posts before you stop reviewing | Assume it read the docs | ## Next - [Publish from CI](https://docs.morsecms.xyz/publish-from-ci.html): The same key model on a schedule. - [Use Morse with AI](https://docs.morsecms.xyz/use-morse-with-ai.html): llms.txt, Markdown twins and the Ask AI shortcut. - [Add a co-author](https://docs.morsecms.xyz/co-author.html): What a cap is and how to revoke it. --- # Prove a post has not changed > Three claims, three commands. Verify the bytes, the revision and the author of any post from a clean machine. - About 10 minutes - CLI, no key needed - A publication id and entry id - Testnet ## What you are checking A Morse post makes three claims you can test without trusting the publisher or us: the content you are reading is the bytes that were registered, the revision you are reading is the one the publisher marked public, and the wallet that published it is the one that holds a cap. Each claim has a command. | Claim | Where the proof lives | Command | | --- | --- | --- | | These bytes are the registered bytes | The Walrus blob object the revision points to | `entry read` without `--via-aggregator` | | This is the public revision | Entry record on Sui, inside the collection | `entry get` | | A cap holder published it | The revision's author on Sui | `entry get`, then `cap list` | ## Get the record All you need is the publication id, the collection name and the entry id. No key: reads by object id do not use an account. ```bash $ morse --json entry get 0 -P 0x69d4...f327 -C posts | jq . ``` Keep three values from the output: `publicHead` (the index of the public revision), that revision's `blobRef.blobObjectId` (the Sui object of its Walrus blob) and its `author`. Those three are the record. ## Fetch the bytes two ways The aggregator is convenient. The direct path is the proof. ```bash $ morse entry read 0 -P 0x69d4...f327 -C posts --via-aggregator --out a.md # fast, trusts the aggregator $ morse entry read 0 -P 0x69d4...f327 -C posts --out b.md # verifies against storage nodes $ cmp a.md b.md && echo "bytes match" ``` Without the flag, the CLI fetches slivers from Walrus storage nodes and checks them against the blob's metadata before writing a byte. If it succeeds, the file matches the blob id on chain. If the bytes had been swapped anywhere, this step fails rather than returning something else. ## Walk the revisions Every earlier revision is still there. Read the first one and diff it against the current public head: ```bash $ morse entry read 0 0 -P 0x69d4...f327 -C posts --out v0.md $ diff v0.md b.md ``` This is what the demo blog's Revisions panel does for a reader with a browser. A reporter, a lawyer or a regulator can do it with these two lines and no relationship to the publisher. ## Check who published it The record names the wallet that wrote each revision, so no explorer is needed: `jq -r '.revisions[.publicHead].author'` on the `entry get` output prints it. To see the transactions themselves, open the publication id on a Sui explorer, for example `suiscan.xyz/testnet/object/`. Then check that the author held a cap for the publication: ```bash $ morse cap list 0xAUTHOR_ADDRESS ``` The list includes revoked caps, marked `[revoked]`, so it shows every cap the author was issued, not only the ones that still work. A cap revoked since then is still enough here: the chain rejects any write made with a revoked cap, so a revision that exists was authorised by the owner when it was written. ## Do it from a script The whole check as one script, suitable for a cron job or a CI step that guards a site built from Morse content: ```bash #!/usr/bin/env bash set -euo pipefail PUB=$1; COL=$2; ENTRY=$3 tmp=$(mktemp -d) morse --json entry get "$ENTRY" -P "$PUB" -C "$COL" > "$tmp/record.json" morse -q entry read "$ENTRY" -P "$PUB" -C "$COL" --out "$tmp/verified" \ || { echo "direct read failed: blob unavailable or expired"; exit 5; } morse -q entry read "$ENTRY" -P "$PUB" -C "$COL" --via-aggregator --out "$tmp/served" cmp -s "$tmp/verified" "$tmp/served" && echo "OK: served bytes match the verified blob" \ || { echo "MISMATCH: aggregator served different bytes"; exit 1; } ``` ```bash $ ./verify.sh 0x69d4...f327 posts 0 ``` ## Next - [Fix a typo without losing history](https://docs.morsecms.xyz/fix-a-typo.html): Where revisions come from. - [Contracts reference](https://docs.morsecms.xyz/contracts-reference.html): The Entry and Revision objects you just read. - [Core concepts](https://docs.morsecms.xyz/core-concepts.html): Publications, collections, entries, revisions. --- # Publications > A publication is the top-level permission management and identity boundary for a content domain in Morse. Everything (collections, entries, permissions) lives inside a publication. ## What is a publication? A `Publication` is a shared Sui object that acts as the root container for one content brand or site. It bundles: - A human-readable **name** (display label) - An immutable, globally unique **slug** (canonical URL key) - A **collection map** holding all named content lanes - A **revocation denylist** for publisher capabilities ```move public struct Publication has key { id: UID, name: String, slug: String, collections: VecMap, revoked_publisher_caps: Table, } ``` The `Publication` type has `key` ability but no `store`. Sharing and transfer are gated through this module's own functions, enforced by the Sui type system. You cannot wrap or freely transfer a publication; you must call `share_publication`. ## The slug system Every publication is identified by a **slug**: a short, lowercase string that becomes its canonical identifier across the protocol. Slugs are enforced to be globally unique by the `PublicationRegistry`. ### Slug rules | Rule | Constraint | | --- | --- | | Characters | Lowercase letters `a-z`, digits `0-9`, hyphens `-` | | Length | 1 to 64 characters | | Edge hyphens | Cannot start or end with `-` | | Uniqueness | Must be globally unique while the publication is active | | Immutability | Cannot be changed after publication creation | ```examples // valid slugs "my-blog" "acme-corp" "docs2025" // invalid slugs "-leading-hyphen" // ESlugInvalidEdgeHyphen "trailing-hyphen-" // ESlugInvalidEdgeHyphen "UPPERCASE" // ESlugInvalidChar "spaces not allowed" // ESlugInvalidChar "" // ESlugEmpty ``` ### Slug reuse after deletion When a publication is deleted, its slug is released from the registry and becomes available again. This is a deliberate tradeoff: it gives publishers flexibility to re-register slugs, but teams should be aware that a released slug can be re-claimed by anyone. ## PublicationRegistry The `PublicationRegistry` is a **shared singleton** object created once in the package `init` function. It maintains a global `Table` mapping slugs to publication IDs, enforcing uniqueness across all publications. ```move public struct PublicationRegistry has key { id: UID, slugs: Table, } // Look up a slug public fun contains_slug(registry: &PublicationRegistry, slug: String): bool // Resolve slug → publication ID public fun get_publication_id_by_slug(registry: &PublicationRegistry, slug: String): &ID ``` The registry is a mandatory argument to `new_publication`. You cannot create a publication without passing the shared registry object. To list publications owned by your account, use the CLI: `morse publication list`. You can also fetch a specific publication by slug with `morse publication get `. See [CLI Commands → Publication](https://docs.morsecms.xyz/cli-commands.html#publication) for the full syntax. ## Creating a publication Publication creation follows the **pure-function pattern**: `new_publication` returns objects without performing any transfers. The caller decides what to do with them in a Programmable Transaction Block (PTB). **CLI:** ```bash morse publication create --name --slug ``` The new publication is automatically selected as the active publication. ```example morse publication create --name "My Blog" --slug my-blog ``` ```output Created "My Blog" (0x1a2b...3c4d) ownerCap: 0x5e6f... publisherCap: 0x7a8b... tx: AbCdEf... Selected as the active publication. ``` **SDK:** ```typescript import { createPublication } from "@arcadiasystems/morse-sdk"; // See sdk-client-setup.html for adapter and config initialization const pub = await createPublication(adapter, config, { name: "My Blog", slug: "my-blog", }); // pub → { publicationId, ownerCapId, publisherCapId, digest, gasUsedMist } ``` **Move:** ```move public fun new_publication( registry: &mut PublicationRegistry, name: String, slug: String, ctx: &mut TxContext, ): (Publication, OwnerCap, PublisherCap) // Share the publication so other transactions can access it public fun share_publication(publication: Publication) // Transfer caps using the contract's typed wrappers (key-only, no store) public fun transfer_owner_cap(owner_cap: OwnerCap, recipient: address) public fun transfer_publisher_cap(cap: PublisherCap, recipient: address) ``` Use [SDK Setup → morseConfig()](https://docs.morsecms.xyz/sdk-client-setup.html#morse-config) to get the package ID and registry ID, [SDK Setup → Server setup](https://docs.morsecms.xyz/sdk-client-setup.html#server-setup) to initialize the client, and [Contract Cookbooks](https://docs.morsecms.xyz/contracts-workflows.html) for complete PTB examples. `new_publication` returns three objects in a tuple: - **Publication**: the shared content container - `OwnerCap`: admin authority over the publication (one per publication, held by the owner wallet). See [Access Control → OwnerCap](https://docs.morsecms.xyz/access-control.html#ownercap) - `PublisherCap`: write authority for content operations, bound to the transaction sender. See [Access Control → PublisherCap](https://docs.morsecms.xyz/access-control.html#publishercap) ## Deleting a publication Publications can be deleted, but only when **all collections have been removed first**. Attempting to delete a publication with remaining collections aborts with an error. **CLI:** All commands target the active publication. Empty every collection first (entries, then the collection itself), then delete the publication. Pass `-y` before the subcommand to skip the prompt (e.g. `morse -y publication delete `). ```bash morse publication delete [] ``` Omit `[]` to target the active one: ```example morse publication delete my-blog ``` **SDK:** ```typescript import { deletePublication } from "@arcadiasystems/morse-sdk"; // reader verifies all collections are empty before submitting the tx await deletePublication(reader, adapter, config, { publicationId: "0x1a2b...3c4d", ownerCapId: "0x5e6f...", }); ``` **Move:** ```move public fun delete_publication( registry: &mut PublicationRegistry, publication: Publication, owner_cap: OwnerCap, ) ``` On deletion: 1. The `OwnerCap` is consumed and its UID deleted 2. The slug is removed from the registry, `SlugReleased` event is emitted 3. The revocation denylist is dropped 4. The publication UID is deleted 5. A `PublicationDeleted` event is emitted > **WARNING: Collections must be empty before deletion** > > The `VecMap` of collections will abort on `destroy_empty` if any collections remain. Remove all collections (and their entries) before calling `delete_publication`. > **INFO: `PublisherCap`s are orphaned on deletion** > > Deleting a publication does not destroy outstanding `PublisherCap` objects. Any caps issued to other addresses remain as owned Sui objects, but they become permanently unusable. The publication they reference no longer exists, so all write operations will fail. Revoke or destroy caps before deleting the publication if you want a clean teardown. ## Ownership transfer Publication ownership can be transferred by transferring the `OwnerCap` to a new address. Since `OwnerCap` has only the `key` ability, transfer is handled by the module: **CLI:** Targets the active publication. Pass `--publication ` to target a different one. Pass `-y` before the subcommand to skip the prompt (e.g. `morse -y publication transfer-ownership `). ```bash morse publication transfer-ownership [--publication ] ``` ```example morse publication transfer-ownership 0x71a3...9f2b ``` **SDK:** ```typescript import { transferOwnership } from "@arcadiasystems/morse-sdk"; await transferOwnership(adapter, config, { ownerCapId: "0x5e6f...", recipient: "0x71a3...9f2b", }); ``` **Move:** ```move public fun transfer_owner_cap(owner_cap: OwnerCap, recipient: address) ``` The recipient immediately gains full admin authority: the ability to issue/revoke publisher caps and delete the publication. Existing publisher caps remain valid until individually revoked. After receiving the `OwnerCap`, the recipient must set the publication as active in their CLI before they can manage it: `morse use `. ## Publisher cap management Publishers are managed through capability tokens. An owner can issue, revoke, and destroy publisher caps. Revoked caps are added to the publication's denylist, and any future operation attempted with a revoked cap aborts with `EPublisherCapRevoked`. See [Access Control → PublisherCap](https://docs.morsecms.xyz/access-control.html#publishercap) for the full API with Move, CLI, and SDK examples. ## Events The publications module emits the following events. An off-chain indexer can subscribe to these to maintain a live view of the publication registry. | Event | When emitted | Fields | | --- | --- | --- | | `PublicationCreated` | New publication created | `publication: ID`, `name: String`, `slug: String` | | `PublicationDeleted` | Publication deleted | `publication: ID`, `name: String` | | `SlugRegistered` | Slug claimed in registry | `slug: String`, `publication: ID` | | `SlugReleased` | Slug freed on deletion | `slug: String`, `publication: ID` | | `PublisherCapIssued` | New publisher cap minted | `publication: ID`, `cap: ID` | | `PublisherCapRevoked` | Publisher cap revoked by owner | `publication: ID`, `cap: ID` | ## Error codes For more error codes, see: [Collections](https://docs.morsecms.xyz/collections.html#error-codes), [Entries](https://docs.morsecms.xyz/entries.html#error-codes), [Access Control](https://docs.morsecms.xyz/access-control.html#error-codes). | Code | Constant | Meaning | | --- | --- | --- | | `6` | `ESlugAlreadyExists` | A publication with this slug already exists in the registry | | `7` | `ESlugEmpty` | The provided slug is an empty string | | `8` | `ESlugTooLong` | Slug exceeds 64 characters | | `9` | `ESlugInvalidChar` | Slug contains a character that is not `a-z`, `0-9`, or `-` | | `10` | `ESlugInvalidEdgeHyphen` | Slug starts or ends with a hyphen | | `2` | `EUnauthorized` | Capability does not belong to this publication | | `4` | `EPublisherCapWrongHolder` | Publisher cap is bound to a different address than the transaction sender | | `5` | `EPublisherCapRevoked` | Publisher cap has been revoked by the owner | | `0` | `ECollectionAlreadyExists` | A collection with this name already exists | --- # Collections > A collection is a named content lane inside a publication. It groups related entries together and determines how their Walrus payload references are stored. ## What is a collection? Collections organize entries into predictable buckets for editorial workflows and API consumers. A publication holds collections in a `VecMap` keyed by name. The choice of `VecMap` reflects the expectation that a publication will have only a small number of named collections. ```move public struct Collection has store { name: String, storage_mode: u8, // 0 = blob, 1 = quilt; immutable after creation next_entry_id: u64, // monotonic counter entries: Table, } ``` ## Storage modes Every collection has a `storage_mode` set at creation that controls how entry revisions reference Walrus content. ### Blob mode In blob mode, every revision stores a `BlobRef::Blob(ID)`, the Sui object ID of a standalone Walrus `Blob`. Each entry revision is fully independent: uploading new content creates a new blob, and the old blobs remain accessible by their IDs. **Best for:** - Articles, blog posts, documentation pages - Media libraries (images, videos, audio) - Any collection where entries change independently and frequently - Situations where you want maximum operational simplicity ### Quilt mode In quilt mode, every revision stores a `BlobRef::QuiltPatch(vector)`, a 37-byte identifier that locates a patch within a shared Walrus Quilt blob. This is more compact and efficient when updating small parts of a grouped content set. ``` ┌──────────────────────────────┬─────────┬───────────────┬─────────────┐ │ quilt_blob_id (32 bytes) │ ver (1) │ start_idx (2) │ end_idx (2) │ └──────────────────────────────┴─────────┴───────────────┴─────────────┘ Walrus blob ID of the quilt version start index end index (this is the Walrus blob_id, (u16 LE) (u16 LE) not the Sui object ID) ``` The contract validates that every `QuiltPatchId` is exactly 37 bytes. Shorter or longer byte vectors are rejected with `EInvalidQuiltPatchId`. **Best for:** - Website navigation sections or config-like structured documents - Compact content sets updated in place (e.g. featured banners, site metadata) - Stable grouped content with targeted patch updates ### Choosing a storage mode | Scenario | Recommended mode | | --- | --- | | Growing blog / article feed | Blob (0) | | Frequently updated individual documents | Blob (0) | | Media / asset library | Blob (0) | | Static site sections (header, footer, nav) | Quilt (1) | | Config JSONs updated as a group | Quilt (1) | | Small, bounded content sets | Quilt (1) | > **WARNING: Storage mode is immutable** > > Once a collection is created with a given `storage_mode`, it cannot be changed. All entries in the collection must use references that match the collection's storage mode. The contract enforces this on every write. ## Creating a collection Collections are created through the publication module's `create_collection` function, which requires a valid [PublisherCap](https://docs.morsecms.xyz/access-control.html#publishercap). A collection cannot exist outside of a [publication](https://docs.morsecms.xyz/publications.html); every collection is scoped to exactly one. **CLI:** The `--mode` flag sets the storage mode (`blob` or `quilt`); defaults to `blob` if omitted. ```bash morse collection create [--mode ] ``` The new collection is automatically selected as the active collection, so follow-up `entry` commands target it by default. ```example morse collection create articles --mode blob morse collection create site-pages --mode quilt ``` ```output Created collection "articles". Selected as the active collection. (tx: AbCdEf...) ``` **SDK:** ```typescript import { createCollection } from "@arcadiasystems/morse-sdk"; // See sdk-client-setup.html for adapter and config initialization // publicationId and publisherCapId come from createPublication(); see the Publications page const result = await createCollection(adapter, config, { publicationId: "0x1a2b...3c4d", publisherCapId: "0x7a8b...", name: "articles", storageMode: "blob", }); // result → { digest, gasUsedMist } ``` **Move:** ```move public fun create_collection( publication: &mut Publication, cap: &PublisherCap, name: String, storage_mode: u8, // 0 = blob, 1 = quilt ctx: &mut TxContext, ) ``` Use [SDK Setup → morseConfig()](https://docs.morsecms.xyz/sdk-client-setup.html#morse-config) to get the package ID and registry ID, [SDK Setup → Server setup](https://docs.morsecms.xyz/sdk-client-setup.html#server-setup) to initialize the client, and [Contract Cookbooks](https://docs.morsecms.xyz/contracts-workflows.html) for complete PTB examples. Collection names must be unique within a publication. Creating a collection with a name that already exists aborts with `ECollectionAlreadyExists`. > **WARNING: Quilt mode limitations** > > Quilt-mode collections can be created, but the CLI's `morse entry add` command does not yet support populating them. To work with quilt-mode entries, use the SDK or construct PTBs directly. Blob mode is recommended for most use cases. To list collections in a publication, use the CLI: `morse collection list`. See [CLI Commands → Collection](https://docs.morsecms.xyz/cli-commands.html#collection) for the full syntax. ## Deleting a collection Collections can be deleted, but only when **all entries have been removed first**. **CLI:** Pass `-y` before the subcommand to skip the prompt (e.g. `morse -y collection delete `). ```bash morse collection delete ``` ```example morse collection delete articles ``` **SDK:** ```typescript import { deleteCollection } from "@arcadiasystems/morse-sdk"; await deleteCollection(adapter, config, { publicationId: "0x1a2b...3c4d", publisherCapId: "0x7a8b...", name: "articles", }); ``` **Move:** ```move public fun delete_collection( publication: &mut Publication, cap: &PublisherCap, name: String, ctx: &mut TxContext, ) ``` Entries must be removed first because the `Table` that holds them requires an explicit `destroy_empty`. Sui's type system does not allow dropping a non-empty table. Delete entries individually with `delete_entry_from_collection` before removing the collection. ## Events Collection lifecycle events are emitted by the `publication` module. | Event | When emitted | Fields | | --- | --- | --- | | `CollectionAdded` | Collection created in a publication | `publication: ID`, `name: String` | | `CollectionRemoved` | Collection deleted from a publication | `publication: ID`, `name: String` | ## Error codes For more error codes, see: [Publications](https://docs.morsecms.xyz/publications.html#error-codes), [Entries](https://docs.morsecms.xyz/entries.html#error-codes), [Access Control](https://docs.morsecms.xyz/access-control.html#error-codes). | Code | Constant | Meaning | | --- | --- | --- | | `0` | `ECollectionAlreadyExists` | A collection with this name already exists in the publication (from `publication` module) | | `0` | `EEntryNotFound` | No entry exists for the requested `entry_id` (from `collection` module) | | `1` | `EInvalidStorageMode` | Storage mode must be `0` or `1` | | `11` | `EInvalidQuiltPatchId` | Quilt patch ID must be exactly 37 bytes (from `entry` module) | --- # Entries & Revisions > An entry is a single content unit within a collection. Revisions are its immutable history. The dual-head model lets drafts evolve freely while production readers always see a stable published state. ## Entry structure Entries live inside collections, stored in a `Table`. They have `store` and `drop` abilities; they are values owned by their collection, not independent Sui objects. ```move public struct Entry has store, drop { name: String, revisions: vector, draft_head: Option, // index into revisions public_head: Option, // index into revisions } ``` The `name` field must be non-empty and at most 256 characters. ## Revisions Each revision is an immutable snapshot of an entry at a point in time. Revisions are never mutated after creation; edits always append new revisions to the vector. ```move public struct EntryRevision has store, drop { blob_ref: BlobRef, // Walrus content reference content_type: String, // MIME type (e.g. "application/json") encrypted: bool, access_policy: u8, // 0=public, 1=publisher, 2=subscription seal_id: Option>, // required when encrypted=true author: address, // tx sender at time of write } ``` The `content_type` field must be non-empty and at most 255 characters; standard MIME format is recommended. ### The BlobRef enum `BlobRef` is a Move enum that abstracts the two storage modes (the SDK surfaces it as a discriminated union; switch on `.kind`): ```move public enum BlobRef has copy, drop, store { Blob(ID), // Sui object ID of a standalone Walrus Blob QuiltPatch(vector), // 37-byte QuiltPatchId } ``` The variant used must match the collection's [storage mode](https://docs.morsecms.xyz/collections.html#storage-modes). The `make_blob_ref` function enforces this; you cannot accidentally mix modes. ## The dual-head model Every entry maintains two independent head pointers into its revision vector: - **`draft_head`**: Points to the latest draft revision. Work in progress, not visible to public readers. May be `None` on a freshly-published entry. - **`public_head`**: Points to the currently published revision. Stable for readers. May be `None` on an entry that has only drafts. ``` revisions: [rev0, rev1(draft), rev2(draft), rev3(public), rev4(draft)] ↑ public_head=3 ↑ draft_head=4 ``` This is what makes real editorial workflows possible: editors can keep appending drafts while production clients continuously serve the stable `public_head`. ## Adding an entry Create a new entry and add it to a named collection. Requires a valid [PublisherCap](https://docs.morsecms.xyz/publications.html#publisher-cap-management). Returns the assigned `entry_id`. An entry cannot exist outside of a [collection](https://docs.morsecms.xyz/collections.html); every entry is scoped to exactly one. **CLI:** The `--file` flag provides the content; `--content-type` is inferred from the file extension if omitted. `--epochs` controls Walrus storage duration (defaults to 3). ```bash morse entry add --file [--content-type ] [--epochs ] ``` ```example echo '{"title":"Welcome","body":"Hello, Morse!"}' > welcome.json morse entry add "welcome-post" --file ./welcome.json --content-type application/json --epochs 5 ``` ```output Added entry #0 "welcome-post". (tx: AbCdEf...) ``` **SDK:** ```typescript import { addEntryFromBytes } from "@arcadiasystems/morse-sdk"; // See sdk-client-setup.html for adapter, config, and walrusWriter initialization // publicationId and publisherCapId come from createPublication() - see Publications page const result = await addEntryFromBytes(adapter, config, { walrus: walrusWriter, publicationId: "0x1a2b...3c4d", publisherCapId: "0x7a8b...", collectionName: "articles", name: "welcome-post", bytes: new TextEncoder().encode("Hello, Morse!"), contentType: "text/plain", upload: { epochs: 5, deletable: true }, }); // result → { entryId, digest, gasUsedMist } ``` **Move:** Upload content to Walrus first using `DefaultWalrusWriteAdapter` from `morse-sdk`. Pass the resulting Blob object ID in the PTB. ```typescript const content = new TextEncoder().encode( JSON.stringify({ title: "Welcome", body: "Hello, Morse!" }) ); const { blobObjectId: BLOB_OBJECT_ID } = await walrusWriter.uploadBlob(content, { epochs: 3, deletable: true, }); ``` ```move public fun add_entry_to_collection( publication: &mut Publication, cap: &PublisherCap, collection_name: String, name: String, blob: &Blob, // must be deletable quilt_patch_id: Option>, // Some for quilt mode, None for blob mode content_type: String, encrypted: bool, access_policy: u8, seal_id: Option>, ctx: &mut TxContext, ): u64 // returns entry_id ``` Use [SDK Setup → morseConfig()](https://docs.morsecms.xyz/sdk-client-setup.html#morse-config) to get the package ID and registry ID, [SDK Setup → Server setup](https://docs.morsecms.xyz/sdk-client-setup.html#server-setup) to initialize the client, and [Contract Cookbooks](https://docs.morsecms.xyz/contracts-workflows.html) for complete PTB examples. For unencrypted public entries: `encrypted = false`, `access_policy = 0`, `seal_id = None`. The initial revision goes directly to `public_head`. > **WARNING: Encrypted entries are always created as drafts** > > When `encrypted = true` and `access_policy = 1` (publisher), the initial revision goes to `draft_head` only; there is no `public_head` yet. Published revisions are always unencrypted and public, so to publish an encrypted entry you must supply a new unencrypted blob using `publish_from_draft` or `publish_direct`. A revision cannot be both published and encrypted. To list entries in a collection, use the CLI: `morse entry list`. The command supports pagination with `--limit` and `--cursor` flags, and `--drafts-only` to filter for draft entries. See [CLI Commands → Entry](https://docs.morsecms.xyz/cli-commands.html#entry) for the full syntax. ## Appending a draft revision Append a new revision to an existing entry and advance `draft_head`. The previous revisions are untouched. **CLI:** Upload new content and append it as a draft revision on an existing entry. The `draft_head` advances; `public_head` is unchanged. ```bash morse revision append-draft --file [--content-type ] [--epochs ] ``` ```example echo '{"title":"Welcome","body":"Updated content"}' > welcome-v2.json morse revision append-draft 0 --file ./welcome-v2.json --content-type application/json ``` ```output Appended draft revision #1 on entry #0. (tx: AbCdEf...) ``` **SDK:** ```typescript import { appendDraftRevision } from "@arcadiasystems/morse-sdk"; // Revision functions require a pre-uploaded blob - upload to Walrus first. const blob = await walrusWriter.uploadBlob( new TextEncoder().encode("updated content"), { epochs: 3, deletable: true }, ); // Append the draft using the blob's on-chain object ID. const result = await appendDraftRevision(adapter, config, { publicationId: "0x1a2b...3c4d", publisherCapId: "0x7a8b...", collectionName: "articles", entryId: 0, blobObjectId: blob.blobObjectId, contentType: "application/json", }); ``` **Move:** Upload content to Walrus first using `DefaultWalrusWriteAdapter` from `morse-sdk`. Pass the resulting Blob object ID in the PTB. ```typescript const content = new TextEncoder().encode( JSON.stringify({ title: "Welcome", body: "Updated content" }) ); const { blobObjectId: BLOB_OBJECT_ID } = await walrusWriter.uploadBlob(content, { epochs: 3, deletable: true, }); ``` ```move public fun append_collection_entry_draft_revision( publication: &mut Publication, cap: &PublisherCap, collection_name: String, entry_id: u64, blob: &Blob, quilt_patch_id: Option>, content_type: String, encrypted: bool, access_policy: u8, seal_id: Option>, ctx: &mut TxContext, ): u64 // returns new revision_id ``` ## Publishing from draft Take a specific draft revision and publish it. This appends a new public revision and advances `public_head`. The draft revision is referenced for traceability. **CLI:** Promote a draft to public. By default, reuses the draft's existing content. Pass `--file` to publish replacement content instead. Appends a new public revision and advances `public_head`. ```bash morse revision publish-from-draft [--file ] [--content-type ] [--epochs ] ``` Without replacement content (reuses the draft's blob): ```example morse revision publish-from-draft 0 1 ``` With replacement content: ```example echo '{"title":"Welcome","body":"Final version"}' > welcome-final.json morse revision publish-from-draft 0 1 --file ./welcome-final.json ``` ```output Published revision #2 from draft #1 on entry #0. (tx: AbCdEf...) ``` **SDK:** ```typescript import { publishFromDraft } from "@arcadiasystems/morse-sdk"; // Upload the final content to Walrus - the published revision needs its own blob. const blob = await walrusWriter.uploadBlob( new TextEncoder().encode("final content"), { epochs: 3, deletable: true }, ); // Promote the draft; the new public revision references this blob. const result = await publishFromDraft(adapter, config, { publicationId: "0x1a2b...3c4d", publisherCapId: "0x7a8b...", collectionName: "articles", entryId: 0, draftRevisionId: 1, blobObjectId: blob.blobObjectId, contentType: "application/json", }); ``` **Move:** Upload content to Walrus first using `DefaultWalrusWriteAdapter` from `morse-sdk`. Pass the resulting Blob object ID in the PTB. ```typescript const content = new TextEncoder().encode( JSON.stringify({ title: "Welcome", body: "Final version" }) ); const { blobObjectId: BLOB_OBJECT_ID } = await walrusWriter.uploadBlob(content, { epochs: 3, deletable: true, }); ``` ```move public fun publish_collection_entry_from_draft( publication: &mut Publication, cap: &PublisherCap, collection_name: String, entry_id: u64, draft_revision_id: u64, // reference to the specific draft blob: &Blob, quilt_patch_id: Option>, content_type: String, ctx: &mut TxContext, ): u64 // returns new revision_id ``` The resulting public revision always has `encrypted = false` and `access_policy = ACCESS_PUBLIC`. ## Publishing direct Append a new public revision directly, skipping the draft workflow. Advances `public_head` in one step. **CLI:** ```bash morse revision publish-direct --file [--content-type ] [--epochs ] ``` ```example echo '{"title":"Welcome","body":"Hotfix content"}' > welcome-hotfix.json morse revision publish-direct 0 --file ./welcome-hotfix.json --content-type application/json ``` ```output Published revision #3 on entry #0. (tx: AbCdEf...) ``` **SDK:** ```typescript import { publishDirect } from "@arcadiasystems/morse-sdk"; // Upload the new content to Walrus first. const blob = await walrusWriter.uploadBlob( new TextEncoder().encode("updated content"), { epochs: 3, deletable: true }, ); // Publish directly using the blob's on-chain object ID. const result = await publishDirect(adapter, config, { publicationId: "0x1a2b...3c4d", publisherCapId: "0x7a8b...", collectionName: "articles", entryId: 0, blobObjectId: blob.blobObjectId, contentType: "application/json", }); ``` **Move:** Upload content to Walrus first using `DefaultWalrusWriteAdapter` from `morse-sdk`. Pass the resulting Blob object ID in the PTB. ```typescript const content = new TextEncoder().encode( JSON.stringify({ title: "Welcome", body: "Hotfix content" }) ); const { blobObjectId: BLOB_OBJECT_ID } = await walrusWriter.uploadBlob(content, { epochs: 3, deletable: true, }); ``` ```move public fun publish_collection_entry_direct( publication: &mut Publication, cap: &PublisherCap, collection_name: String, entry_id: u64, blob: &Blob, quilt_patch_id: Option>, content_type: String, ctx: &mut TxContext, ): u64 ``` ## Entry IDs When an entry is added to a collection, it is assigned a stable, monotonically increasing `entry_id: u64`. The counter starts at `0` and increments with each insertion. - Entry IDs are **never reused** after deletion - IDs are **stable**; external systems can safely hold references by ID - Gaps in the ID sequence are expected and normal after deletions ```example id sequence // Add entries: IDs assigned sequentially entry_id = add_entry_to_collection(...) // → 0 entry_id = add_entry_to_collection(...) // → 1 entry_id = add_entry_to_collection(...) // → 2 // Delete entry 1 delete_entry_from_collection(..., entry_id: 1, ...) // Next add gets ID 3 ID 1 is never reused entry_id = add_entry_to_collection(...) // → 3 ``` ## Deleting an entry Remove an entry by ID. Since `Entry` has the `drop` ability, it is simply dropped from the table. **CLI:** Delete an entry and all its revisions. This cannot be undone. Pass `-y` before the subcommand to skip the prompt (e.g. `morse -y entry delete `). ```bash morse entry delete ``` ```example morse entry delete 0 ``` **SDK:** ```typescript import { deleteEntry } from "@arcadiasystems/morse-sdk"; await deleteEntry(adapter, config, { publicationId: "0x1a2b...3c4d", publisherCapId: "0x7a8b...", collectionName: "articles", entryId: 0, }); ``` **Move:** ```move public fun delete_entry_from_collection( publication: &mut Publication, cap: &PublisherCap, collection_name: String, entry_id: u64, ctx: &mut TxContext, ) ``` > **NOTE: Blob objects are independent** > > Deleting an entry removes the on-chain record and the `BlobRef`, but does not delete the underlying Walrus `Blob` object on Sui. Blob lifecycle management (storing, deleting the blob object from Walrus) is the caller's responsibility. ## Typical editorial workflow The workflow below applies to both Blob and Quilt storage modes. In Quilt mode, entries target a specific patch within the shared document rather than a standalone blob, but the revision lifecycle is the same. 1. **Add the entry** Call `add_entry_to_collection` with your Walrus blob. This creates revision 0. For public content, `public_head = Some(0)` immediately. For encrypted content, `draft_head = Some(0)`. 2. **Iterate in draft** Call `append_collection_entry_draft_revision` as many times as needed. Each call appends a new revision and advances `draft_head`. Readers still see the old `public_head`. 3. **Publish** Call `publish_collection_entry_from_draft` (referencing a specific `draft_revision_id`) or `publish_collection_entry_direct`. Both append a new public revision and advance `public_head`. Readers now see the new content. 4. **Continue iterating** Append more drafts after publishing. The cycle continues without limit. Every revision is preserved in the immutable history vector. ## Walrus blob requirements All production write paths require a reference to the Walrus `&Blob` object (not just its ID). The contract calls `blob.is_deletable()` and aborts with `EBlobNotDeletable` if the blob is not deletable. This ensures the platform can manage blob lifecycle. ### Blob lifecycle: epochs, expiry, renewal The on-chain revision history is immutable, but the payloads it points to are not stored "forever" by default: Walrus storage is **funded per epoch** (roughly one day on testnet, two weeks on mainnet). The `epochs` parameter on every upload sets how long the blob stays available; after its final epoch the blob expires and storage nodes drop it, while the on-chain `BlobRef` and revision metadata remain. Renew a blob before expiry (or re-upload and append a new revision) for longer-lived content. Morse deliberately requires *deletable* blobs rather than Walrus's permanent variant: a CMS needs unpublishing, correction, and takedown paths. Permanence in Morse means the **history is verifiable and tamper-proof**, not that bytes are irrevocable. > **WARNING: Testnet payloads are disposable** > > The quick starts upload with `epochs: 3`, about three days on testnet. Walrus testnet is also wiped periodically (every few months). Treat testnet content as throwaway; fund more epochs and set renewal reminders only for things you actually want to keep around. > **TIP: Testing without Walrus blobs** > > In Move unit tests, `walrus::blob::Blob` cannot be constructed because its constructor is `public(package)`. Use the `_for_testing` variants (e.g. `add_entry_to_collection_for_testing`) that accept a raw `blob_id: ID` instead of `&Blob`. These bypass only the deletable check; all other logic is identical. ## Access policy & encryption rules The `encrypted` flag and `access_policy` must be consistent. The contract enforces this at every write. See [Access Control](https://docs.morsecms.xyz/access-control.html) for the full permission model. | encrypted | access_policy | seal_id | Result | | --- | --- | --- | --- | | `false` | `0` (PUBLIC) | `None` | Valid: unencrypted public revision | | `true` | `1` (PUBLISHER) | `Some()` | Valid: encrypted publisher-gated revision | | `true` | `0` | any | Aborts: `EInvalidAccessPolicy` | | `false` | `1` | any | Aborts: `EInvalidAccessPolicy` | | `true` | `1` | `None` | Aborts: `ESealIdRequired` | | `false` | `0` | `Some()` | Aborts: `ESealIdNotAllowed` | `ACCESS_SUBSCRIPTION = 2` is defined in the protocol but reserved for future use. ## Events Entry and revision operations do not emit on-chain events. Use the transaction log or an indexer to track changes. ## Error codes For more error codes, see: [Publications](https://docs.morsecms.xyz/publications.html#error-codes), [Collections](https://docs.morsecms.xyz/collections.html#error-codes), [Access Control](https://docs.morsecms.xyz/access-control.html#error-codes). | Code | Constant | Meaning | | --- | --- | --- | | `0` | `ENameEmpty` | Entry name is empty | | `1` | `EContentTypeEmpty` | Content type is empty | | `2` | `ENameTooLong` | Entry name exceeds 256 characters | | `3` | `EContentTypeTooLong` | Content type exceeds 255 characters | | `4` | `ERevisionNotFound` | Requested revision index does not exist | | `5` | `EInvalidAccessPolicy` | Encrypted/access_policy mismatch | | `6` | `ESealIdRequired` | Encrypted revision must include a Seal identity | | `7` | `ESealIdNotAllowed` | Unencrypted revision must not include a Seal identity | | `8` | `EBlobNotDeletable` | Walrus blob must be deletable | | `9` | `EQuiltPatchIdRequired` | Quilt mode collection requires a QuiltPatchId | | `10` | `EQuiltPatchIdNotAllowed` | Blob mode collection must not include a QuiltPatchId | | `11` | `EInvalidQuiltPatchId` | QuiltPatchId must be exactly 37 bytes | --- # Access Control > Morse uses a capability-based permission model. Two types of capabilities control who can administer a publication and who can write content, enforced on-chain by the Sui type system, with no off-chain authorization service. ## Overview Every write operation in Morse requires presenting a valid capability object. Capabilities cannot be fabricated; they can only be created by the protocol through designated functions. The type system itself is the enforcement mechanism. ## OwnerCap The `OwnerCap` is the highest-authority object in a publication. Exactly one exists per publication; it is created alongside the publication and transferred to the creator. ```move public struct OwnerCap has key { id: UID, publication_id: ID, // which publication this cap administers } ``` Operations that require `OwnerCap`: - `issue_publisher_cap`: mint a new publisher cap for an address - `revoke_publisher_cap`: add a cap ID to the revocation denylist - `delete_publication`: delete the publication entirely - `transfer_owner_cap`: transfer ownership to a new address > **INFO: `OwnerCap` is transferable by design** > > Ownership can be sold, transferred to a multisig wallet, or handed to a DAO treasury. This is a protocol-level feature, not a limitation: publication identity lives in the contract, not in a centralized identity system. ## PublisherCap Publisher caps grant write access for daily content operations. Many can exist per publication, issued to different addresses for different editors or automation agents. ```move public struct PublisherCap has key { id: UID, publication_id: ID, // scoped to this publication holder: address, // only this address may use this cap } ``` Every write operation that uses a `PublisherCap` runs three checks internally: ```move assert!(cap.publication_id == object::id(publication), EUnauthorized); assert!(cap.holder == ctx.sender(), EPublisherCapWrongHolder); assert!(!publication.revoked_publisher_caps.contains(object::id(cap)), EPublisherCapRevoked); ``` The `holder` field is critical: it binds the capability to a specific address. If someone holds the `PublisherCap` object but the transaction is not signed by `holder`, the write is rejected. You cannot use a single publisher cap from multiple wallets. ## Issuing publisher caps Only the owner can issue publisher caps. The owner specifies the target `holder` address. The cap is created and delivered in a single command: **CLI:** ```bash morse cap issue ``` ```example morse cap issue 0x71a3...9f2b ``` ```output Issued PublisherCap 0x9c0d...1e2f to 0x71a3...9f2b. (tx: AbCdEf...) ``` **SDK:** ```typescript import { issuePublisherCap } from "@arcadiasystems/morse-sdk"; // See sdk-client-setup.html for adapter and config initialization // publicationId and ownerCapId come from createPublication() - see Publications page const result = await issuePublisherCap(adapter, config, { publicationId: "0x1a2b...3c4d", ownerCapId: "0x5e6f...", holder: "0x71a3...9f2b", }); // result → { publisherCapId, digest, gasUsedMist } ``` **Move:** ```move // Step 1: create the cap (returns it to the caller) public fun issue_publisher_cap( publication: &mut Publication, owner_cap: &OwnerCap, holder: address, ctx: &mut TxContext, ): PublisherCap // Step 2: deliver the cap to the holder public fun transfer_publisher_cap(cap: PublisherCap, recipient: address) ``` Use [SDK Setup → morseConfig()](https://docs.morsecms.xyz/sdk-client-setup.html#morse-config) to get the package ID and registry ID, [SDK Setup → Server setup](https://docs.morsecms.xyz/sdk-client-setup.html#server-setup) to initialize the client, and [Contract Cookbooks](https://docs.morsecms.xyz/contracts-workflows.html) for complete PTB examples. ### Ownership vs holder binding A `PublisherCap` has two layers of authorization that must both be satisfied: - **Sui object ownership**: which wallet physically holds the cap object. This determines who can pass it into a transaction. - **`holder` binding**: an address set permanently at issuance. The contract checks `cap.holder == ctx.sender()` on every write operation. Both must match for the cap to work. Transferring a cap to someone who is not the bound `holder` gives them the object, but the contract will still reject their writes. ### The `transfer_publisher_cap` function At the Move level, `issue_publisher_cap` returns the cap to the caller without delivering it. A separate `transfer_publisher_cap` call moves the object to the intended recipient. The CLI and SDK bundle both calls into a single atomic transaction, so the publisher receives the cap immediately when you run `morse cap issue`. The CLI also exposes `morse cap transfer` as a standalone command for manually re-sending an existing cap, but this is rarely needed in practice since issuance handles delivery automatically. > **WARNING: Transfer to a non-holder creates a dead object** > > Transferring a cap to an address that is not the bound `holder`, the cap becomes permanently stuck: the recipient owns the Sui object but write and destroy fail the `holder` check, and the original holder can no longer access the object. To rotate access to a new address, destroy the old cap and have the owner issue a new one. ### Under the hood When you issue a cap via the CLI or SDK, two Move calls are bundled in a single transaction: 1. `issue_publisher_cap` creates the cap and binds it to the `holder` address. 2. `transfer_publisher_cap` delivers the cap object to that address. **No table write occurs during issuance**; only revocations write to the denylist. This makes issuing caps essentially free beyond the gas cost of object creation. ## Revoking publisher caps When a publisher needs to be removed, the owner calls `revoke_publisher_cap` with the cap's object ID: **CLI:** Pass `-y` before the subcommand to skip the prompt (e.g. `morse -y cap revoke `). ```bash morse cap revoke ``` ```example morse cap revoke 0x9c0d...1e2f ``` ```output Revoked PublisherCap 0x9c0d...1e2f. (tx: AbCdEf...) ``` **SDK:** ```typescript import { revokePublisherCap } from "@arcadiasystems/morse-sdk"; await revokePublisherCap(adapter, config, { publicationId: "0x1a2b...3c4d", ownerCapId: "0x5e6f...", publisherCapId: "0x9c0d...1e2f", }); ``` **Move:** ```move public fun revoke_publisher_cap( publication: &mut Publication, owner_cap: &OwnerCap, cap_id: ID, // object ID of the PublisherCap to revoke ) ``` This adds the cap ID to the `revoked_publisher_caps: Table` denylist on the publication. All subsequent writes using that cap will be rejected. The revocation is immediate and requires no cooperation from the cap holder. > **WARNING: Double-revoke aborts** > > Calling `revoke_publisher_cap` on an already-revoked cap ID aborts with `EPublisherCapRevoked`. Check the denylist state before revoking if unsure. ### The denylist model Morse uses a **denylist** (blocklist), not an allowlist, for publisher cap tracking. This is a deliberate design choice: | | Denylist (Morse) | Allowlist (alternative) | | --- | --- | --- | | Issue cap | Zero table writes | One table write per issuance | | Revoke cap | One table write | One table delete | | Write validation | One table lookup | One table lookup | | Optimized for | Common case (no revocations) | Frequent churn | This is safe because `PublisherCap` has no `store` ability and no external constructor; caps cannot be forged outside the module. An attacker cannot create a valid-looking cap; they must obtain a real one from the owner. ## Destroying publisher caps A publisher can voluntarily surrender their write access by calling `destroy_publisher_cap`: **CLI:** Pass `-y` before the subcommand to skip the prompt (e.g. `morse -y cap destroy `). ```bash morse cap destroy ``` ```example morse cap destroy 0x9c0d...1e2f ``` ```output Destroyed PublisherCap 0x9c0d...1e2f. (tx: AbCdEf...) ``` **SDK:** ```typescript import { destroyPublisherCap } from "@arcadiasystems/morse-sdk"; await destroyPublisherCap(adapter, config, { publicationId: "0x1a2b...3c4d", publisherCapId: "0x9c0d...1e2f", }); ``` **Move:** ```move public fun destroy_publisher_cap( publication: &mut Publication, cap: PublisherCap, // consumed ctx: &mut TxContext, ) ``` Only the `holder` can call this. The cap is permanently deleted from Sui and can never be used again. If it was previously revoked, the denylist entry is also removed to free on-chain storage. ## Seal integration Morse integrates with **Seal**, Mysten's on-chain access control layer, to gate decryption of encrypted entry payloads. The integration is implemented via an `entry fun` that Seal calls during the decryption approval flow. ### Decryption sequence When a reader wants to decrypt an encrypted entry, the SDK and Seal key servers coordinate a multi-step flow: 1. **Client creates a `SessionKey`**: the reader's wallet signs a personal message to produce a time-limited `SessionKey` scoped to the contract's `originalPackageId` (the stable genesis address that survives upgrades). 2. **SDK builds an approval transaction**: `seal.decrypt()` decodes the `sealId` to extract the target `publicationId`, then constructs a Programmable Transaction Block (PTB) that calls `seal_approve_publisher` with the `sealId`, the `Publication` object, and the caller's `PublisherCap`. 3. **SDK sends the request to Seal key servers**: the serialized PTB bytes, the ciphertext, and the `SessionKey` are sent to the configured Seal key servers. 4. **Key servers dry-run the approval on-chain**: each key server executes the PTB against the current chain state. The Move function runs two checks (see below). If either check fails the key server refuses to release its share. 5. **Key servers return threshold decryption shares**: once at least *threshold* servers (default 2) confirm approval, each returns its decryption key share to the client. 6. **Client decrypts locally**: the `SealClient` combines the shares and decrypts the Walrus blob ciphertext on the reader's device. ### Approval function The on-chain function that Seal key servers dry-run during step 4: ```move entry fun seal_approve_publisher( id: vector, // Seal identity being approved publication: &Publication, cap: &PublisherCap, ctx: &TxContext, ) ``` The function runs two checks: 1. `assert_active_publisher_cap`: verifies the cap is valid, not revoked, and the sender is the holder 2. `assert_valid_publisher_seal_id`: verifies the Seal identity is scoped to this publication and policy ### Seal identity format Seal identities for Morse follow a structured namespace format to prevent cross-publication identity reuse: ``` ┌───────────────────────────┬────────────────┬──────────────────────┐ │ publication_id bytes (32) │ policy_tag (1) │ nonce (variable) │ └───────────────────────────┴────────────────┴──────────────────────┘ Scopes identity to one Must be 0x01 Differentiates specific publication for publisher multiple identities policy under same namespace ``` The `policy_tag = 0x01` is the publisher policy tag. This namespacing ensures: - A cap for Publication A cannot approve decryption of Publication B content - The same publication can host multiple policy types with different tags in the future (e.g. subscription tag) - Many unique Seal identities can exist within one publication namespace (via different nonces) ## Access policies Every revision stored on-chain carries an `access_policy` field that determines who can read the content payload. The Move contract validates the policy value, encryption flag, and Seal identity together at write time; invalid combinations abort the transaction. | Policy | Value | Encrypted | Who can read | Enforcement | | --- | --- | --- | --- | --- | | **Public** | `0` | No | Anyone. The Walrus blob is readable directly by blob ID. | `encrypted` must be `false`, `seal_id` must be absent. Enforced at write time by the entry module. | | **Publisher** | `1` | Yes | Holders of an active (non-revoked) `PublisherCap` for the publication. | `encrypted` must be `true`, `seal_id` required. Decryption gated on-chain by `seal_approve_publisher` (see [Seal Integration](#seal-integration) above). | | **Subscription** | `2` | - | Planned: subscribers who hold a valid subscription token. | Reserved. The constant is defined and the value is stored correctly on-chain, but no `seal_approve_subscription` entry function exists yet. | > **WARNING: Policy and encryption are coupled** > > Public revisions must be unencrypted; publisher revisions must be encrypted with a Seal identity. The contract rejects mismatches at write time. See [Entries → Error codes](https://docs.morsecms.xyz/entries.html#error-codes) for the specific error constants. ## Real-world usage pattern 1. **Publication created** Owner wallet creates the publication. Receives `OwnerCap` (kept in owner wallet) and an initial `PublisherCap` (also kept or immediately transferred). 2. **Onboard editors** For each editor or automation agent, the owner calls `issue_publisher_cap(holder: editor_address)` and transfers the returned cap to that address. Each cap is independent; revoking one does not affect others. 3. **Editors publish content** Editors present their `PublisherCap` in PTBs to create collections, add entries, append drafts, and publish. The contract verifies sender matches `holder` on every call. 4. **Offboard editors** When an editor leaves, the owner calls `revoke_publisher_cap(cap_id)`. The revocation is immediate; the next write attempt using that cap aborts. Historical content is unaffected. 5. **Transfer ownership (optional)** The owner calls `transfer_owner_cap(owner_cap, new_owner_address)`. The new owner gains full admin authority immediately. The old owner retains only their `PublisherCap` (if any) unless it is revoked by the new owner. See [Publications → Ownership transfer](https://docs.morsecms.xyz/publications.html#ownership-transfer) for details. ## Error codes For more error codes, see: [Publications](https://docs.morsecms.xyz/publications.html#error-codes), [Collections](https://docs.morsecms.xyz/collections.html#error-codes), [Entries](https://docs.morsecms.xyz/entries.html#error-codes). | Code | Constant | Meaning | | --- | --- | --- | | `2` | `EUnauthorized` | Capability does not belong to this publication | | `4` | `EPublisherCapWrongHolder` | Transaction sender is not the cap's bound holder address | | `5` | `EPublisherCapRevoked` | The cap ID is on the revocation denylist | | `12` | `ESealInvalidId` | Seal identity does not match this publication's namespace | | `13` | `ESealWrongPolicyTag` | Seal identity has an unsupported policy tag byte | --- # Recipient Files > Recipient files are Morse's second content model: standalone files on Walrus with a per-file recipient list enforced on-chain. Encrypt a file for specific addresses, share it with a single string, and revoke access at decrypt time. No publication required. ## Overview Publications, collections, and entries model structured CMS content: a site with editors, drafts, and revision history. Recipient files model something simpler: *"share THIS file with these addresses."* Each file is an independent shared object on Sui (`RecipientFile`, defined in the `recipient_file` Move module) that references a Walrus blob and embeds its own recipient set. There is no shared ACL across files and no capability objects involved. | | Publication entries | Recipient files | | --- | --- | --- | | Container | Publication → Collection → Entry | None: each file stands alone | | Write authority | `PublisherCap` (capability object) | `owner` address field on the file | | Read gating | Publisher Seal policy (tag `1`): any active cap holder | Recipient-file Seal policy (tag `3`): addresses in the file's `members` set | | Revision history | Append-only revisions | None: metadata is mutable, content is fixed at creation | | Use for | Sites, blogs, structured content | Ad-hoc encrypted file sharing between wallets | Use recipient files when the unit of sharing is a single file and the audience is a specific list of wallets: contracts, reports, credentials, media handoffs. Use [entries](https://docs.morsecms.xyz/entries.html) when content belongs to a publication and is gated by publisher capabilities. > **INFO: Supersedes the legacy `file` + `allowlist` modules** > > Earlier contract versions modeled encrypted files as an `EncryptedFile` paired with a separate `Allowlist` object. The `recipient_file` module embeds the recipient set directly on the file and replaces that pair. The legacy modules remain in the package bytecode for old clients, but all new code (SDK and CLI included) uses `recipient_file`. ## The RecipientFile object A `RecipientFile` is created in the same transaction that certifies its Walrus blob, then immediately shared so Seal key servers can dry-run its approval function regardless of who asks: ```move public struct RecipientFile has key { id: UID, owner: address, // may mutate metadata, edit recipients, transfer, delete blob_id: vector, // Walrus content id (32 bytes) blob_object_id: Option, // on-chain Walrus Blob object, when known name: String, // original filename (max 256 chars) content_type: String, // MIME type (max 255 chars) size: u64, // plaintext byte length members: VecSet
, // addresses that may decrypt created_at_ms: u64, // Sui clock timestamp, set once } ``` Key semantics, enforced by the contract: - **The creator is always a member.** `new_recipient_file` auto-inserts the transaction sender into `members`; duplicates in the recipients argument (including a self-reference) are silently deduplicated. - **Owner and member are separate roles.** The `owner` field grants mutation rights; `members` grants decrypt access. The owner can remove themselves from `members`; they lose decrypt access but keep mutation rights. - **`size` is the plaintext length.** The Walrus blob stores the larger ciphertext (Seal adds an envelope). The field is informational for UIs, not validated against Walrus. - **Metadata is mutable, content is not.** Only `name` and `content_type` can change after creation. To change the content, upload a new file. - **Shared object.** The creation PTB calls `share_recipient_file`, so the file belongs to no wallet. Anyone can read the record; only `owner` passes the mutation checks. ### Move operations | Function | Authority | Effect | | --- | --- | --- | | `new_recipient_file` | Anyone | Create a file record (returned by value; share it in the same PTB). Sender auto-added to `members`. | | `new_recipient_file_with_seal_prefix` | Anyone | Same, plus binds a caller-supplied Seal identity prefix to the file via a dynamic field. Used by all encrypted upload flows. | | `share_recipient_file` | Creator (same PTB) | Share the object so key servers can dry-run approval. | | `add_recipient` | Owner | Add an address to `members`. Aborts if already present. | | `remove_recipient` | Owner | Remove an address from `members`. Aborts if not present. | | `update_metadata` | Owner | Replace `name` and `content_type`. | | `transfer_ownership` | Owner | Set a new `owner`. Does *not* touch `members`. | | `delete_file` | Owner | Delete the metadata record. The Walrus blob is untouched. | | `seal_approve` / `seal_approve_with_prefix` | Seal key servers (dry-run) | Abort unless the sender is a current member and the Seal identity matches the file's namespace. | Every mutation emits an event (`RecipientFileCreated`, `RecipientAdded`, `RecipientRemoved`, `RecipientFileMetadataUpdated`, `RecipientFileOwnershipTransferred`, `RecipientFileDeleted`, `RecipientFileSealPrefixAttached`). These events are the discovery mechanism. See [Listing & Discovery](#listing-and-discovery). ## Encryption & the recipient list Encrypted recipient files use **Seal**, the same threshold-encryption layer that gates encrypted entries, but under a second, independent policy. Both policies coexist in the same contract package: | | Publisher policy | Recipient-file policy | | --- | --- | --- | | Policy tag | `1` | `3` | | Identity prefix | Publication ID (32 bytes) | Caller-chosen random bytes (32 recommended), bound to the file at creation | | Approval function | `publication::seal_approve_publisher` | `recipient_file::seal_approve_with_prefix` (or `seal_approve` for legacy files) | | Who may decrypt | Holders of an active `PublisherCap` | Addresses in the file's `members` set | | Documented at | [Access Control → Seal integration](https://docs.morsecms.xyz/access-control.html#seal-integration) | This page | ### Seal identity format A recipient-file Seal identity is the byte concatenation of a prefix, the policy tag, and a per-encryption nonce: ``` ┌────────────────────────────┬────────────────┬───────────────────────┐ │ seal_id_prefix (≥1 byte) │ policy_tag (1) │ nonce (≥1 byte) │ └────────────────────────────┴────────────────┴───────────────────────┘ Random, caller-chosen. Must be 0x03 Fresh per encryption. 32 bytes recommended. for the 16 bytes recommended. Bound to the file on-chain recipient-file at creation. policy. ``` The prefix solves an ordering problem: a file's Sui object ID does not exist until its creation transaction is signed, but encryption must happen *before* upload, which happens before that transaction. So the client picks a random prefix, encrypts under `prefix || 0x03 || nonce`, and binds the same prefix to the file on-chain via `new_recipient_file_with_seal_prefix`. This is what makes the whole upload fit in a single certify-and-create PTB. The SDK exposes the recommended sizes as `RECOMMENDED_SEAL_PREFIX_BYTES` (32) and `RECOMMENDED_SEAL_NONCE_BYTES` (16), and generates values with `randomSealPrefix()` / `randomSealNonce()`. Files created via the older `new_recipient_file` path use the file's object ID itself as the prefix and are approved by the plain `seal_approve` entry point; `get_seal_id_prefix` tells clients which variant a file uses. ### Decryption sequence Decryption is gated on-chain, at decrypt time, by the current recipient set: 1. **Recipient creates a `SessionKey`**: their wallet signs a personal message producing a time-limited key scoped to the contract package. The SDK never builds session keys silently; the consumer always signs. 2. **SDK builds an approval PTB**: `decryptUnderRecipientFile` constructs a transaction calling `recipient_file::seal_approve_with_prefix` with the Seal identity and the shared `RecipientFile` object. 3. **Seal key servers dry-run it**: each server executes the PTB against current chain state. The Move function checks that the identity starts with the file's bound prefix, carries policy tag `3`, and that the requesting address is in `members`. Any failure and the server refuses its share. 4. **Threshold shares are returned**: once the threshold (default 2) is met, the client combines shares and decrypts the Walrus ciphertext locally. > **INFO: Revocation is enforced at decrypt time** > > Because key servers re-check `members` on every request, `remove_recipient` takes effect immediately: the removed address gets `SealError` code `no-access` on its next decrypt attempt (verified on testnet). Revocation cannot claw back plaintext a recipient already decrypted; it stops future key-share requests, nothing more. ## CLI workflows The `morse file` command group covers the full lifecycle. See [CLI Overview](https://docs.morsecms.xyz/cli-overview.html) for installation and account setup. ### Upload & share One command encrypts, uploads to Walrus, and registers the file on-chain. Pass `-r/--recipient` once per recipient; the sender is always added automatically. `--encrypt` is implied when `-r` is given. ```bash morse file upload -n --encrypt [-r ]... [--content-type ] [--epochs ] ``` ```example morse file upload ./launch-plan.md -n launch-plan.md -r 0x71a3...9f2b -r 0x88c2...41de ``` ```output Uploaded encrypted file 0x5f0a...c37e blobId: kD3x...vQ8 share: mf1.MHg1ZjBh...LmM4.4kfx....9dQz tx: 4N94D7Zn... Give the share string to recipients; they need it (plus membership) to decrypt. ``` The **share string** (`mf1...`, each part base64url) bundles everything a recipient needs to locate and decrypt the file. It survives copy-paste and chat. It is not a secret capability by itself (decryption still requires the recipient's address to be in the on-chain member set), but treat it as sensitive: the prefix and nonce are not recoverable from the ciphertext, so a lost share string means a member cannot decrypt. For a world-readable file, use `--public` instead of `--encrypt`. The bytes go to Walrus unencrypted and the CLI prints a direct aggregator URL (`/v1/blobs/`). Anyone can fetch it without the CLI at all. `--public` and `-r` are mutually exclusive. ### Download Recipients decrypt with the share string. The CLI reads the on-chain record, fetches the ciphertext from Walrus, signs a short-lived `SessionKey` (10 minutes) with the active account, and decrypts locally: ```bash morse file download --share --out [--via-aggregator] ``` - `--share` carries the file ID, prefix, and nonce in one token. Alternatively pass the file ID positionally with `--prefix --nonce `. - `--raw` writes the stored bytes without decrypting. For an encrypted file (one with a Seal prefix attached on-chain) and no decrypt input, the CLI refuses by default (the ciphertext is unusable), and `--raw` is the explicit opt-in. - `--out` writes to a file; omitting it streams to stdout (not available in `--json` mode). The encrypted round-trip is byte-for-byte: the downloaded plaintext is identical to the uploaded file (verified on testnet). A non-member running the same command gets `SealError` code `no-access`; a tampered ciphertext fails with `decrypt-failed`. > **WARNING: Freshly uploaded blobs may fail the default read path on testnet** > > Right after an upload, the default verifying Walrus read often fails with "No valid blob metadata could be retrieved from any storage node" while testnet storage nodes catch up. Pass `--via-aggregator` to fetch through the Walrus aggregator HTTP service instead; it is reliable immediately, at the cost of client-side blob verification. ### Managing recipients The owner can grant and revoke decrypt access at any time; changes take effect on the next decrypt attempt: ```bash morse file recipient add
morse file recipient remove
morse file recipient list ``` A newly added recipient also needs the share string (prefix + nonce) to decrypt; membership alone locates the key shares but not the identity bytes. Send it over any channel; the on-chain member check is what actually gates access. ### Lifecycle commands | Command | Description | | --- | --- | | `file get ` | Fetch the on-chain metadata record. | | `file list [--address ] [--accessible] [--hydrate] [--limit ]` | List files owned by (or, with `--accessible`, decryptable by) an address, via event indexing. `--hydrate` fetches full records (one read per file). | | `file register --blob-id -n --content-type --size (--public \| --encrypted --seal-prefix ) [-r ]...` | Register on-chain metadata for a blob already on Walrus, e.g. one uploaded out-of-band. | | `file update -n --content-type ` | Update name and content type. Owner only. | | `file transfer-ownership ` | Transfer mutation rights. Recipient access is governed separately by the member list. Prompts; pass `-y` before the subcommand to skip. | | `file delete ` | Delete the metadata record. The Walrus blob is *not* deleted; it expires on its own storage lease. Prompts; `-y` skips. | > **WARNING: Ownership transfer does not move decrypt access** > > `transfer_ownership` changes only the `owner` field. For a full handover (new owner gains decrypt access, old owner loses it), also run `recipient add ` and `recipient remove ` (or compose all three calls in one PTB via the SDK). ## SDK usage All recipient-file APIs are exported from the `@arcadiasystems/morse-sdk` root. The examples below assume the adapter, config, and client setup from [SDK Setup](https://docs.morsecms.xyz/sdk-client-setup.html). ### Encrypted upload `uploadEncryptedRecipientFileFromBytes` is the one-shot flow: it picks a random Seal prefix and nonce, encrypts, uploads to Walrus, and creates the file, in two wallet interactions (Walrus `register_blob`, then a combined `certify_blob + new_recipient_file_with_seal_prefix + share` PTB): ```typescript import { DefaultSealAdapter, DefaultWalrusWriteAdapter, uploadEncryptedRecipientFileFromBytes, } from "@arcadiasystems/morse-sdk"; const seal = DefaultSealAdapter.fromMorseConfig(config, {}, client); const walrus = DefaultWalrusWriteAdapter.fromConfig( { network: "testnet", suiClient: client }, keypair, ); const result = await uploadEncryptedRecipientFileFromBytes(adapter, config, { walrus, seal, plaintext: new TextEncoder().encode("the launch plan"), recipients: ["0x71a3...9f2b"], // sender is auto-added on-chain name: "launch-plan.md", contentType: "text/markdown", upload: { epochs: 3, deletable: true }, }); // result → { fileId, blobId, blobObjectId, digest, gasUsedMist, // sealIdPrefix, sealNonce } // Persist sealIdPrefix + sealNonce: recipients need both to decrypt, // and they are not recoverable from the ciphertext. ``` For an unencrypted file, use `uploadRecipientFileFromBytes` (same shape, no `seal`, `bytes` instead of `plaintext`). To register a blob you uploaded separately, use `createRecipientFile` (public) or `createEncryptedRecipientFile` (takes the `sealIdPrefix` the blob was encrypted under). Both upload helpers require a Walrus write adapter that implements `startBlobUpload` (`DefaultWalrusWriteAdapter` does); if the second transaction fails after the upload succeeded, they throw `UncertifiedBlobError` with the stranded blob's IDs. ### Decrypt with a SessionKey Decryption needs the file ID, the Seal prefix and nonce, and a `SessionKey`. The SDK never signs on your behalf; creating the session key is an explicit personal-message signature by the consumer: ```typescript import { SessionKey } from "@mysten/seal"; import { buildRecipientFileSealId, HttpAggregatorReadAdapter, RpcRecipientFilesReader, } from "@arcadiasystems/morse-sdk"; // 1. Read the on-chain record and fetch the ciphertext from Walrus. const filesReader = RpcRecipientFilesReader.fromConfig(client, config); const file = await filesReader.getRecipientFile(result.fileId); const walrusReader = HttpAggregatorReadAdapter.fromMorseConfig(config, client); const ciphertext = await walrusReader.readBlob(file.blobId); // 2. Sign a SessionKey (wallet personal-message signature). const sessionKey = await SessionKey.create({ address: keypair.toSuiAddress(), packageId: config.originalPackageId ?? config.packageId, ttlMin: 10, signer: keypair, // or `signer` in the browser suiClient: client, }); // 3. Rebuild the Seal identity and decrypt. const sealId = buildRecipientFileSealId(result.sealIdPrefix, result.sealNonce); const plaintext = await seal.decryptUnderRecipientFile(ciphertext, { sessionKey, sealId, fileId: file.id, }); console.log(new TextDecoder().decode(plaintext)); ``` If the session key's address is not in the file's member set, the key servers refuse and the SDK throws `SealError` with code `no-access`. `decodeRecipientFileSealId(id, prefixLength)` splits an identity back into prefix, tag, and nonce when you need to inspect one. ### Listing & discovery `RecipientFile` objects are *shared*, so they belong to no wallet and never appear in owned-object queries; you cannot enumerate them with `getOwnedObjects`. Discovery is event-based instead: every lifecycle mutation emits an event, and the SDK ships pure reducers that replay an event stream into a current-state list: ```typescript import { buildRecipientFileEventTypes, reconcileRecipientFilesAccessibleBy, reconcileRecipientFilesOwnedBy, } from "@arcadiasystems/morse-sdk"; // Event types are rooted at the package where the module was FIRST // defined - NOT the current packageId, which moves on every upgrade. const types = buildRecipientFileEventTypes( config.recipientFileEventOriginPackageId ?? config.packageId, ); // Fetch events of these types from your event source (Sui GraphQL or an indexer), // normalized to { type, json, timestampMs }, oldest first. Then reduce: const owned = reconcileRecipientFilesOwnedBy(events, myAddress, types); const accessible = reconcileRecipientFilesAccessibleBy(events, myAddress, types); // Both return RecipientFileSummary[] - no blobId/blobObjectId. Hydrate // individual files via filesReader.getRecipientFile(summary.id). ``` The reducers handle creation, metadata updates, recipient add/remove, ownership transfer, and deletion. One known gap: `reconcileRecipientFilesOwnedBy` does not add files transferred *to* the queried address, because the transfer event does not carry the file's metadata. Re-fetch those with `getRecipientFile`. The CLI's `morse file list` uses these same reducers over events read from Sui GraphQL (the canonical Mysten endpoint for the network by default; pass `--indexer-url` to point at another GraphQL endpoint, which localnet requires). ### Types & config reference | Export | What it is | | --- | --- | | `RecipientFileId` | Branded string for a `RecipientFile` object ID. Validate with `toRecipientFileId()`. | | `RecipientFile` | Full live-object record: `id`, `owner`, `blobId`, `blobObjectId`, `name`, `contentType`, `size`, `members`, `createdAtMs`. | | `RecipientFileSummary` | Event-derived view (`kind: "summary"`). Missing `blobId` / `blobObjectId`; hydrate when needed. | | `RecipientFileFull` / `RecipientFileSummaryOrFull` | Hydrated variant (`kind: "full"`) and the discriminated union the list flows return. | | `MorseRecipientFileConfig` | Config slice for recipient-file ops: `packageId`, `originalPackageId`, `recipientFileEventOriginPackageId`. Satisfied by `morseConfig({ network })`. | | `SealPolicyTag` | Policy tag constants: `Publisher = 1`, `RecipientFile = 3`. | The `recipientFileEventOriginPackageId` field exists because Sui stamps events (and created-object types) with the package address where a struct was *first* defined. The `recipient_file` module arrived in a later upgrade than the publication modules, so it has its own origin address, distinct from both `packageId` (current published-at) and `originalPackageId` (v1 genesis). `morseConfig({ network: "testnet" })` carries the correct value; only custom deployments need to set it. ## Costs Measured on Sui testnet against the production package (2026-07-27, 127-byte file). Walrus storage is paid separately in WAL at upload time: | Operation | Gas (MIST) | Gas (SUI) | | --- | --- | --- | | `file upload --encrypt` (certify + create + share PTB) | 6,591,240 | ~0.0066 | | `file recipient add` | 1,280,668 | ~0.0013 | | `file recipient remove` | 796,700 | ~0.0008 | Decryption costs no gas: the `seal_approve` dry-run happens on the key servers' side and nothing is executed on-chain. ## Error codes Move abort codes from the `recipient_file` module. The SDK surfaces them as `ContractAbortError`; Seal-side failures surface as `SealError` with codes `no-access`, `decrypt-failed`, `session-expired`, or `rate-limited`. | Code | Constant | Meaning | | --- | --- | --- | | `0` | `EUnauthorized` | Sender is not the file owner | | `1` | `EBlobIdEmpty` | `blob_id` must be non-empty | | `2` | `ENameInvalid` | `name` is empty or over 256 characters | | `3` | `EContentTypeInvalid` | `content_type` is empty or over 255 characters | | `4` | `ERecipientAlreadyPresent` | Address is already a recipient | | `5` | `ERecipientNotPresent` | Address is not a recipient | | `6` | `ESealInvalidId` | Seal identity does not match this file's namespace | | `7` | `ESealWrongPolicyTag` | Seal identity does not carry policy tag `3` | | `8` | `ENoAccess` | Sender is not in the file's member set (raised in the `seal_approve` dry-run; clients see `SealError("no-access")`) | | `9` | `ESealPrefixEmpty` | Caller-supplied `seal_id_prefix` is empty | | `10` | `ESealPrefixMissing` | File has no attached seal prefix; `seal_approve_with_prefix` cannot apply | --- # CLI Overview > `morse-cli` is the command-line client for the Morse decentralized CMS. It signs with a locally encrypted key, stores content on Walrus, and encrypts private entries with Seal: the same protocol surface as the SDK, driven from your terminal. Runs on Node or Bun and targets testnet or Walrus Mainnet. > **INFO: Published on npm · testnet and mainnet** > > Install `@arcadiasystems/morse-cli` from npm. It targets testnet unless you choose mainnet with `--network`, `MORSE_NETWORK` or a profile. Mainnet spends real SUI and WAL, so test on testnet first. ## Installing The published binary is a Node-targeted bundle. You need Node (≥ 18) or Bun (≥ 1.2) to run it. ```bash npm i -g @arcadiasystems/morse-cli morse --help ``` Also available via `bun add -g` or `pnpm add -g`. Or run without installing: ```bash npx @arcadiasystems/morse-cli --help ``` ## Getting started Select an active publication and collection once. All subsequent commands will use them automatically, so you never have to paste a 64-character object ID. `publication create` and `collection create` auto-select the new object. See [CLI Cookbooks](https://docs.morsecms.xyz/cli-workflows.html) for step-by-step guides. ## Active context The active publication and collection live in the current profile. Override per command with `-P, --publication ` and `-C, --collection `. Slugs resolve against publications owned by the active account. - `morse use [collection]`: set the active publication and collection (optional) - `morse use --clear`: clear the active context - `morse status`: show the active profile, account, publication, and collection ## Security model Private keys are stored in an encrypted keystore (scrypt + AES-256-GCM) and unlocked by a password. - Keys are **never accepted as command-line flags**. They come only from an interactive hidden prompt, the `MORSE_PRIVATE_KEY` env var, or the encrypted keystore. - Keystore files are written `chmod 600`; group- or world-readable key files are refused on read. - Keys are never printed or logged, including under `--debug`. The sole exception is `account export`, which deliberately reveals a key: interactive-only, requires confirmation, unavailable in `--json`. - For CI, set `MORSE_PRIVATE_KEY` (raw key, never persisted) and `MORSE_KEYSTORE_PASSWORD` to skip prompts. ## Configuration Settings resolve with the precedence `flags` > `MORSE_* env` > `config file` > `defaults`. The config file lives under `$XDG_CONFIG_HOME/morse` (default `~/.config/morse`); encrypted keystores live in `keystores/` beside it. | Variable | Overrides | Notes | | --- | --- | --- | | `MORSE_PROFILE` | `-p, --profile` | Profile to use. | | `MORSE_NETWORK` | `--network` | `testnet`, `mainnet` or `localnet`. | | `MORSE_RPC_URL` | `--rpc` | Sui RPC URL override. | | `MORSE_ADDRESS` | (no flag) | Active account address. | | `MORSE_PUBLICATION` | `-P, --publication` | Active publication ID. | | `MORSE_COLLECTION` | `-C, --collection` | Active collection name. | | `MORSE_PRIVATE_KEY` | (no flag) | Raw Bech32 secret key. Highest-priority key source; never persisted. | | `MORSE_KEYSTORE_PASSWORD` | (no flag) | Keystore password for non-interactive unlock. | ## Output & scripting - Human-readable output goes to **stdout**; progress, warnings, and prompts go to **stderr**. Redirecting stdout yields only the result. - In `--json` mode, `bigint` values (e.g. `gasUsedMist`) are encoded as decimal strings and byte arrays (`sealId`, quilt patch IDs) as `0x`-hex. - Color is emitted only to a TTY and honors `NO_COLOR` / `FORCE_COLOR`. See [CLI Command Reference → Global options](https://docs.morsecms.xyz/cli-commands.html#global-options) for a full list of global flags (`--json`, `--quiet`, `--yes`, and more). ## Exit codes | Code | Meaning | | --- | --- | | `0` | Success | | `1` | Generic error (contract abort, uncategorized) | | `2` | Usage error (bad flags/args, invalid ID/slug/address, declined confirmation) | | `3` | Not found | | `4` | Auth or permission failure (keystore unlock, unauthorized, no-access) | | `5` | Network or transport failure | ## Next steps - [CLI: Command Reference](https://docs.morsecms.xyz/cli-commands.html): Every command and flag: config, account, publication, collection, entry, revision, cap. - [CLI: Cookbooks](https://docs.morsecms.xyz/cli-workflows.html): Step-by-step recipes: publishing, encrypted entries, multi-writer setups, CI/CD scripting. - [Quick Start: CLI](https://docs.morsecms.xyz/quick-start-cli.html): The protocol concepts the CLI drives: publications, collections, entries. --- # CLI Command Reference > Complete reference for every `morse-cli` command, organized by resource: context, config, account, publication, collection, entry, revision, cap, and file. ## Global options Global options apply to every command and must appear before the subcommand (e.g. `morse --json publication list`). | Option | Purpose | | --- | --- | | `--network ` | Network to target (default: testnet). | | `-p, --profile ` | Config profile to use. | | `--rpc ` | Override the Sui RPC URL. | | `--upload-relay ` | Upload Walrus blobs through a relay instead of sending them to every storage node; `auto` picks the network's canonical relay. Costs a tip per upload. | | `--max-tip ` | Cap the relay's per-upload tip, in MIST (default: 10000000). Only applies with `--upload-relay`. | | `--json` | Machine-readable JSON on stdout. | | `-q, --quiet` | Suppress progress and informational output. | | `-y, --yes` | Assume yes for confirmation prompts. | | `--debug` | Print stack traces on error. | | `-V, --version` | Print the version. | | `-h, --help` | Show help for any command. | ## Shared command flags These flags appear across multiple command groups. Command-specific flags are shown inline in each category below. | Short | Long | Purpose | | --- | --- | --- | | `-P` | `--publication ` | Target publication (default: active context). | | `-C` | `--collection ` | Target collection (default: active context). | | `-f` | `--file ` | Input file (`-` for stdin). Used by content commands. | | `-n` | `--name ` | Name of a publication (`publication create`) or a file (`file upload`, `file register`, `file update`). | | `-s` | `--slug ` | URL slug (used by `publication create`). | ## Argument notation Values in `` are required; values in `[square brackets]` are optional; `(a | b)` means one of the two is required; `...` means the flag can be repeated. | Argument | Format | Example | | --- | --- | --- | | `` | Zero-based integer index within the collection. | `0` | | `[revisionIndex]` | Zero-based integer index within the entry. Latest when omitted. | `0` | | `` | Zero-based integer revision ID of the draft to publish. | `1` | | `` | Sui object ID of a PublisherCap. | `0x3a…` | | `
`, ``, ``, `` | Sui address. | `0xf1…` | | `` | Publication slug or Sui object ID. | `my-blog`, `0x7c…` | | `` | Sui object ID of a RecipientFile. | `0x5f…` | ## Context Set the active publication and collection so subsequent commands use them automatically. | Command | Purpose | | --- | --- | | `use [collection]` | Set the active publication (slug or id) and collection (optional). Omitting the collection clears it. | | `use --clear` | Clear the active context. | | `status` | Show the active profile, network, account, publication, and collection. | ```example $ morse use my-blog posts Switched to publication "my-blog", collection "posts". $ morse status profile: testnet network: testnet account: 0x71a3...9f2b publication: my-blog (0x1a2b...) collection: posts ``` ## Config Configure CLI profiles, each pointing to a different network and account. | Command | Purpose | | --- | --- | | `config add --network [--rpc ] [--upload-relay ]` | Create or update a profile. `--upload-relay` saves a Walrus upload relay on the profile. | | `config list` | List profiles; `*` marks the active. | | `config use ` | Set the active profile. | | `config remove ` | Delete a profile. | | `config path` | Print the config file path. | ```example $ morse config add testnet --network testnet Profile "testnet" created and set as default. ``` ## Account Import, list, and manage keypairs used for signing transactions. | Command | Purpose | | --- | --- | | `account import` | Import a key into an encrypted keystore. Reads the key from stdin when it is piped; otherwise prompts for key + password (hidden). | | `account list` | List imported accounts; `*` marks the active one. | | `account show` | Print the active account address. | | `account use
` | Set the active account for the current profile. | | `account export
` | Reveal a decrypted key (interactive-only, dangerous). | To import in a script, pipe the key on stdin and set `MORSE_KEYSTORE_PASSWORD`. With stdin piped there is no terminal to prompt on, so the password must come from that variable. `MORSE_PRIVATE_KEY`, when set, takes priority over stdin. The key is never a flag, because command-line arguments leak through `ps` and shell history. ```example $ morse account list * 0x71a3...9f2b 0xd4e5...6f7a ``` ## Publication Create and manage publications, the top-level content containers. | Command | Purpose | | --- | --- | | `publication get []` | Fetch a publication (default: active). | | `publication list [
] [--ids-only] [--limit ] [--cursor ]` | List publications owned by an address (default: active account). `--ids-only` returns bare IDs; `--limit` / `--cursor` paginate. | | `publication create --name --slug ` | Create a publication and set it as the active one. | | `publication delete [publication] [--owner-cap ]` | Delete an empty publication. `--owner-cap` overrides auto-resolved OwnerCap. | | `publication transfer-ownership [-P ] [--owner-cap ]` | Transfer the OwnerCap to another address. `--owner-cap` overrides auto-resolved OwnerCap. | ```example $ morse publication create --name "My Blog" --slug my-blog Created publication "My Blog". slug: my-blog publicationId: 0x1a2b...3c4d ownerCapId: 0x5e6f...7a8b ``` ## Collection Create and manage collections within a publication. | Command | Purpose | | --- | --- | | `collection list [-P ]` | List collections in a publication. | | `collection create [--mode blob\|quilt] [-P ] [--publisher-cap ]` | Create a collection and set it as the active one. `--publisher-cap` overrides auto-resolved PublisherCap. | | `collection delete [-P ] [--publisher-cap ]` | Delete an empty collection. `--publisher-cap` overrides auto-resolved PublisherCap. | ```example $ morse collection create posts --mode blob Created collection "posts". Selected as the active collection. (tx: AbCdEf...) ``` ## Entry Add, read, and delete entries within a collection. | Command | Purpose | | --- | --- | | `entry get ` | Fetch a single entry's metadata. | | `entry read [] [--out ] [--via-aggregator]` | Fetch a public entry's content to stdout or a file. Reads the latest revision unless `revisionIndex` is specified; `--via-aggregator` fetches through a Walrus aggregator instead of the storage nodes: more reliable when storage nodes are flaky, with no client-side blob verification. | | `entry list [--limit ] [--cursor ] [--all] [--drafts-only]` | List entries. `--all` fetches every page, the same as `entry scan`, and cannot be combined with `--limit` or `--cursor`. `--drafts-only` filters to unpublished entries. | | `entry scan [--drafts-only]` | List every entry (auto-paginated), the same as `entry list --all`. `--drafts-only` filters to unpublished entries. | | `entry add (--file \| --stdin) [--content-type ] [--epochs ] [--publisher-cap ]` | Upload content and add a new entry. `--file` reads from a file path, `--stdin` reads from standard input; `--content-type` is inferred from file extension when omitted; `--epochs` sets Walrus storage duration (default 3, roughly 3 days on testnet; renew or re-upload before expiry for longer-lived content). | | `entry delete [--publisher-cap ]` | Delete an entry and its revisions. | | `entry add-encrypted (--file \| --stdin) [--content-type ] [--epochs ] [--publisher-cap ]` | Encrypt with Seal, upload, and add a new entry. | | `entry decrypt [] [--out ] [--publisher-cap ] [--via-aggregator]` | Decrypt an encrypted revision; reads the latest unless `revisionIndex` is specified. Signs a SessionKey with the active account. | > **WARNING: Encrypted entries are always created as drafts** > > Encrypted entries are always created as drafts; there is no public head yet. Published revisions are always unencrypted and public, so to publish an encrypted entry you must supply a new unencrypted blob. A revision cannot be both published and encrypted. See [Entries → Adding an entry](https://docs.morsecms.xyz/entries.html#adding-an-entry) for the full explanation. ```example $ morse entry add welcome-post --file ./welcome.json Added entry #0 "welcome-post". (tx: AbCdEf...) https://aggregator.walrus-testnet.walrus.space/v1/blobs/... ``` ## Revision Manage drafts and published revisions of an entry. | Command | Purpose | | --- | --- | | `revision publish-direct (--file \| --stdin) [--content-type ] [--epochs ] [--publisher-cap ]` | Upload content and append a public revision. | | `revision append-draft (--file \| --stdin) [--content-type ] [--epochs ] [--publisher-cap ]` | Upload content and append a draft revision. | | `revision publish-from-draft [--file \| --stdin] [--content-type ] [--epochs ] [--publisher-cap ]` | Publish a draft as a new revision; reuses the draft's content by default, pass `--file`/`--stdin` to replace it. | ```example $ morse revision publish-direct 0 --file ./welcome-v2.json Published revision #1 on entry #0. (tx: AbCdEf...) ``` ## Cap Issue, revoke, and transfer publisher capabilities. | Command | Purpose | | --- | --- | | `cap list [
] [--limit ] [--cursor ]` | List PublisherCaps held by an address. | | `cap issue [-P ] [--owner-cap ]` | Issue a PublisherCap bound to an address. | | `cap revoke [-P ] [--owner-cap ]` | Revoke a PublisherCap. Asks for confirmation; in scripts pass the global `--yes` before the subcommand (`morse --yes cap revoke `). | | `cap destroy [-P ]` | Destroy a PublisherCap you hold. | | `cap transfer ` | Transfer a PublisherCap object. Does not change the `holder` binding; the recipient cannot use or destroy the cap unless they are the bound holder. To rotate access, destroy the old cap and issue a new one. | ```example $ morse cap issue 0x71a3...9f2b Issued PublisherCap 0xCAP_ID to 0x71a3...9f2b. (tx: AbCdEf...) ``` `OwnerCap` and `PublisherCap` IDs are auto-resolved from the active account when the `--owner-cap` / `--publisher-cap` override is omitted. Morse separates ownership from publishing. See [Access Control → OwnerCap](https://docs.morsecms.xyz/access-control.html#ownercap) and [Access Control → PublisherCap](https://docs.morsecms.xyz/access-control.html#publishercap) for how capability-based authorization works. ## File Upload, register, read, list, and manage RecipientFiles on Walrus. A RecipientFile carries its own recipient list: the addresses allowed to decrypt it. See [Recipient Files → CLI workflows](https://docs.morsecms.xyz/recipient-files.html#cli-workflows) for a full walkthrough. | Command | Purpose | | --- | --- | | `file upload --name (--public \| --encrypt \| -r ...) [--content-type ] [--epochs ]` | Upload a file to Walrus and register it on-chain. `--public` stores it unencrypted and prints a view link. `--encrypt` encrypts it with Seal and prints a share string. `-r, --recipient` adds an address that can decrypt it and implies `--encrypt`; the sender is always a recipient. `--content-type` is inferred from the path when omitted; `--epochs` sets Walrus storage duration (default 3). | | `file register --blob-id --name --content-type --size (--public \| --encrypted --seal-prefix ) [-r ...] [--blob-object-id ]` | Register on-chain metadata for a blob already on Walrus. `--size` is the plaintext byte length. `--encrypted` needs the `--seal-prefix` the blob was encrypted under; `-r` applies to encrypted files only. `--blob-object-id` records the Walrus Blob object ID, if known. | | `file download [] [--out ] [--share ] [--prefix --nonce ] [--raw] [--via-aggregator]` | Download a file's content to stdout or a file. Pass a file ID or `--share`. `--share`, or `--prefix` with `--nonce`, decrypts an encrypted file and signs a SessionKey with the active account. Without one, an encrypted file is refused unless `--raw` writes the ciphertext as is. `--via-aggregator` fetches through a Walrus aggregator instead of directly. In `--json` mode, `--out` is required. | | `file list [--address ] [--accessible] [--hydrate] [--limit ] [--indexer-url ]` | List files owned by an address (default: active account), rebuilt from contract events. `--accessible` lists files the address can decrypt as a recipient, not just the ones it owns. `--hydrate` fetches each full record (one read per file); `--limit` caps the results. `--indexer-url` sets the Sui GraphQL endpoint to read events from (default: Mysten's endpoint for the network; required on localnet). | | `file get ` | Fetch a file's on-chain metadata and recipient list. | | `file update --name --content-type ` | Update a file's name and content type. Owner only. | | `file transfer-ownership ` | Transfer a file's ownership. Recipient access does not move with it; the recipient list governs that. Asks for confirmation unless the global `--yes` is passed. | | `file delete ` | Delete a file's metadata record. The Walrus blob is not deleted; it expires with its storage lease. Asks for confirmation unless the global `--yes` is passed. | | `file recipient add
` | Allow an address to decrypt the file. | | `file recipient remove
` | Revoke an address's access to the file. | | `file recipient list ` | List the file's recipients. | An encrypted upload prints a share string (`mf1...`). Save it: the prefix and nonce cannot be recovered from the ciphertext. Recipients need the share string, plus a place on the recipient list, to decrypt with `file download --share`. ```example $ morse file upload ./logo.png --name logo.png --public Uploaded public file 0x5f0a...c37e. (tx: AbCdEf...) view: https://aggregator.walrus-testnet.walrus.space/v1/blobs/... ``` ## Command spellings Some commands accept a second spelling for the same action. Both spellings do the same thing. | Command | Also accepts | | --- | --- | | `account show` | `account get` | | `publication get` | `publication show` | | `entry get` | `entry show` | | `file get` | `file show` | | `entry read` | `entry download` | | `file download` | `file read` | | `config remove` | `config delete` | | `cap transfer` | `cap transfer-ownership` | | `entry scan` | `entry list --all` | The `publication` group also answers to `pub`, as in `morse pub list`. ## Next steps - [CLI: Cookbooks](https://docs.morsecms.xyz/cli-workflows.html): Step-by-step recipes: publishing, encrypted entries, multi-writer setups, CI/CD scripting. - [Quick Start: CLI](https://docs.morsecms.xyz/quick-start-cli.html): Create your first publication, collection, and entry from the terminal. --- # CLI Cookbooks > End-to-end recipes for `morse-cli`: publish content, read it back, encrypt and decrypt, delegate write access, and script it in CI. These use the active context, so most commands need no object IDs. For the full per-command reference, see [CLI Command Reference](https://docs.morsecms.xyz/cli-commands.html). ## Prerequisites | Requirement | Details | Purpose | | --- | --- | --- | | Bun or Node | v1.2+ / ≥ 18 | JavaScript runtime | | Sui wallet | Sui Wallet, Suiet, or any Sui-compatible wallet | Signing transactions on testnet | | SUI + WAL tokens | [SUI faucet](https://faucet.sui.io/) · [Swap SUI for WAL](https://stake-wal.wal.app/?network=testnet) | Gas fees (SUI) and Walrus storage fees (WAL) | | Bech32 private key | A `suiprivkey1...` string | CLI authentication. Export from your wallet's settings | Make sure the CLI is installed and your account is imported before continuing. See [CLI Overview](https://docs.morsecms.xyz/cli-overview.html) for setup. ## Publishing your first entry Create a profile, import a key, then create a publication and a collection. Each `create` command selects the new object as the active one, so the steps after require no IDs. `account import` will prompt for your private key and a keystore password. The collection defaults to `blob` mode when `--mode` is omitted. ```bash morse config add testnet --network testnet morse account import morse publication create --name "My Blog" --slug "my-blog-$(date +%s)" morse collection create posts echo "hello morse" > post.md morse entry add hello --file post.md ``` ```output Uploading 12 bytes to Walrus... Added entry #0 "hello". (tx: D9Yfjk...) view: https://aggregator.walrus-testnet.walrus.space/v1/blobs/0iKeii... ``` A publication's name and slug are immutable on-chain (the slug is the registry's unique key), so there is no rename command. Check the active context any time with `morse status`; switch the active publication (and collection) with `morse use [collection]`. See also: [SDK Cookbooks → Creating a publication](https://docs.morsecms.xyz/sdk-workflows.html#creating-a-publication) ## Reading it back `entry get` shows metadata; `entry read` fetches the public content itself. The `view` link printed by `entry add` opens the same bytes in a browser. ```bash morse entry get 0 morse entry read 0 morse entry read 0 --out got.md ``` See also: [SDK Cookbooks → Reading it back](https://docs.morsecms.xyz/sdk-workflows.html#reading-it-back) ## Listing your content List your publications, collections, and entries in the active context. ```bash morse publication list morse collection list morse entry list morse status ``` ## Publishing a new revision Append a new public revision directly, or stage a draft first and promote it. Revisions are append-only; existing revisions are never edited in place. Two heads track progress: `draftHead` (work in progress) and `publicHead` (the live version). ### Option A: publish direct Skip the draft stage and append a new public revision in one step. This updates `publicHead` immediately. ```bash echo "hello morse, edited" > post.md morse revision publish-direct 0 --file post.md ``` ### Option B: draft then publish Stage a draft revision first (updates `draftHead` only), then promote it to a public revision. By default, `publish-from-draft` reuses the draft's content; pass `--file` to replace it with new content. Useful when you want to review before going live. ```bash echo "hello morse, draft" > draft.md morse revision append-draft 0 --file draft.md morse revision publish-from-draft 0 1 ``` See also: [SDK Cookbooks → Publishing a new revision](https://docs.morsecms.xyz/sdk-workflows.html#publishing-a-new-revision) ## Encrypted content Morse uses [Seal](https://docs.morsecms.xyz/access-control.html#seal-integration) for on-chain access control over encrypted content. When you add an encrypted entry, the CLI handles Seal key negotiation automatically. ```bash echo "members only" > secret.txt morse entry add-encrypted members --file secret.txt morse entry decrypt 1 --out recovered.txt ``` > **WARNING: Encrypted entries are always drafts** > > Encrypted entries are always created as drafts. You must explicitly publish them with a separate transaction after adding. Retrieve encrypted content with `entry decrypt` plus a PublisherCap, not `entry read`. See [Entries](https://docs.morsecms.xyz/entries.html#adding-an-entry) for why encrypted entries bypass the public head. See also: [SDK Cookbooks → Encrypted content](https://docs.morsecms.xyz/sdk-workflows.html#encrypted-content) ## Delegating write access Morse separates ownership (`OwnerCap`) from publishing (`PublisherCap`). The owner issues a cap bound to a co-author's address, granting them write access. Once issued, the co-author can publish entries using their own key. Revoking the cap causes further writes to abort on-chain. See [Access Control → Issuing publisher caps](https://docs.morsecms.xyz/access-control.html#issuing-publisher-caps) for details. As the **owner**, issue a cap to grant write access and revoke it to remove access: ```bash morse cap issue 0xCO_AUTHOR_ADDRESS morse cap revoke 0xCAP_ID ``` As a **cap holder**, list your caps or permanently destroy a specific cap: ```bash morse cap list morse cap destroy 0xCAP_ID ``` To rotate a cap to a different address, destroy the old one and have the owner issue a new cap to the new address. Do not use `morse cap transfer` to send a cap to a different holder. The on-chain `holder` binding is immutable, so the recipient would own the object but be unable to use or destroy it. See also: [SDK Cookbooks → Delegating write access](https://docs.morsecms.xyz/sdk-workflows.html#delegating-write-access) ## Transferring ownership The owner can transfer the publication's `OwnerCap` to another address, handing over full governance: the recipient becomes the new owner and can issue, revoke, and manage caps. ```bash morse publication transfer-ownership 0xNEW_OWNER ``` > **WARNING: One-way for the sender** > > Once transferred, the original owner loses all governance rights immediately. The new owner could transfer back, but there is no built-in undo. See also: [SDK Cookbooks → Transferring ownership](https://docs.morsecms.xyz/sdk-workflows.html#transferring-ownership) ## Deleting and tearing down Deletion is bottom-up: remove entries first, then collections, then the publication. Each container must be empty before it can be deleted. Entry IDs are permanently retired on deletion and never reused. See [Entries → Deleting an entry](https://docs.morsecms.xyz/entries.html#deleting-an-entry) for details. ```bash morse entry delete 0 morse entry delete 1 morse collection delete posts morse publication delete ``` See also: [SDK Cookbooks → Deleting and tearing down](https://docs.morsecms.xyz/sdk-workflows.html#deleting-and-tearing-down) ## Scripting and CI Authenticate with env vars (no prompts), skip confirmations with `--yes`, and parse `--json` on stdout. Reads by object ID never need a key; slug-based reads still require an account address for resolution. ```bash export MORSE_PRIVATE_KEY=suiprivkey1... export MORSE_NETWORK=testnet pub=$(morse --json publication create --name CI --slug "ci-$(date +%s)" | jq -r .publicationId) echo "created $pub" morse --yes publication delete "$pub" morse publication get 0x00 || echo "exit $?" ``` ## Troubleshooting | Code | Message | Cause | | --- | --- | --- | | `2` | No account selected | No key configured. Import one, set `MORSE_PRIVATE_KEY`, or run `account use`. | | `4` | Incorrect password or corrupted keystore | Wrong keystore password (GCM auth-tag mismatch). | | `2` | No publication selected | No active publication. Pass `-P ` or run `morse use`. | | `3` | No publication at 0x… | The ID does not exist on this network. | | `1` | Move abort `ESlugAlreadyExists` | The slug is already registered; slugs are globally unique on the network. Re-run with a different slug (e.g. append a timestamp). | | `5` | Too many failures while writing blob to nodes | Walrus storage nodes are degraded. Retry; check WAL balance. | | `5` | InsufficientGas / out of SUI gas | The active account does not have enough SUI for gas fees. Run `sui client faucet` to request testnet SUI. | | `5` | Insufficient WAL balance | Walrus storage requires WAL tokens. Get WAL from the [Walrus testnet faucet](https://stake-wal.wal.app/?network=testnet). | | `3` | No valid blob metadata could be retrieved from any storage node | The default direct-node read path fails for every blob collection entry in morse-cli 0.10.1, because of a bug in how the SDK passes the blob id. Upgrade to morse-cli 0.11.0. | | `3` | The Walrus storage for this content ended at epoch … | The blob-mode entry's storage has ended, so storage nodes no longer serve it. The message gives the end epoch and the current epoch. Re-upload the content and create a new revision with a longer `--epochs` value. | | `3` | Walrus has no stored copy of this content. | Walrus does not hold the content: it may have expired, been deleted or never been certified. The message gives the id that was read. | | `5` | … Retry, or pass --via-aggregator to read through a Walrus aggregator. | The storage nodes did not return the content, which can happen to a healthy blob. Retry, or add `--via-aggregator`. For a blob-mode entry the message also gives the epoch the blob is stored until. | ## Next steps - [SDK: Overview](https://docs.morsecms.xyz/sdk-overview.html): TypeScript client setup, publishing, reading, and encryption via the SDK. - [Smart Contracts: Overview](https://docs.morsecms.xyz/contracts-overview.html): On-chain modules, object model, and the Move API behind Morse dCMS. --- # SDK Overview > `morse-sdk` is the TypeScript client for the Morse decentralized CMS protocol. It packages Sui transaction building, Walrus blob upload and download, and Seal threshold encryption behind a small, swappable adapter pattern. > **WARNING: When contract addresses change** > > During the pre-release phase, contract addresses change on every redeployment. To pick up the new addresses, bump your `morse-sdk` version. Each release pins the latest `packageId` and `originalPackageId` into `morseConfig()`. Publications and other objects created under a previous contract instance remain on-chain but belong to the old package; they are **not** migrated to the new deployment. ## Getting started Install the SDK and its peer dependencies, then wire a wallet adapter and Walrus configuration for your environment. See [SDK Setup](https://docs.morsecms.xyz/sdk-client-setup.html) for installation and configuration, and [SDK Cookbooks](https://docs.morsecms.xyz/sdk-workflows.html) for step-by-step guides. ## Capabilities - **Adapter pattern**: Four swappable adapters (wallet, Walrus write, Walrus read, Seal) with shipped defaults and easy substitution for custom flows. - **Branded ID types**: PublicationId, PublisherCapId, OwnerCapId, BlobObjectId, WalrusBlobId, QuiltPatchId, and more. Compile-time guards against passing the wrong ID into the wrong call. - **Structured errors**: Every throw extends MorseError. Narrow by class + property (e.g. ContractAbortError.reason); no string parsing. - **2-transaction uploads**: addEntryFromBytes collapses register + certify + add-entry into 2 transactions instead of 3 via a flow-aware Walrus capability. - **End-to-end tests**: End-to-end scripts/phase-N-*.ts run against live testnet (real WAL, real SUI) to verify the public surface on every release. - **97% test coverage**: ~97% line / ~96% function coverage. Lint, typecheck, test, build all gate every change. ## Adapter architecture The SDK is built around four swappable abstractions. Pick the shipped default or implement the interface against whatever signing / storage / encryption stack you already have. - **Wallet**: WalletAdapter · KeypairAdapter · WalletStandardSigner - **Walrus Write**: WalrusWriteAdapter · DefaultWalrusWriteAdapter · HttpPublisherWriteAdapter - **Walrus Read**: WalrusReadAdapter · DefaultWalrusReadAdapter · HttpAggregatorReadAdapter - **Encryption**: SealAdapter · DefaultSealAdapter The rest of the SDK (functions like `createPublication`, `addEntryFromBytes`, the `RpcPublicationReader`) composes these adapters. Swapping an adapter requires no changes to call sites. ## Known limitations - **gRPC client only.** Reader and adapter interfaces are typed against `SuiGrpcClient` from `@mysten/sui/grpc`. The SDK makes no JSON-RPC calls. - **No encrypted publish path.** The Move contract hardcodes `encrypted=false` on `publish_from_draft` and `publish_direct`. Encrypted content stays as drafts. See [SDK Cookbooks → Encrypted content](https://docs.morsecms.xyz/sdk-workflows.html#encrypted-content) for the full lifecycle. - **Only the `Publisher` access policy is enforced.** The `Subscription` policy is not yet active. - **`listEntries` is not sorted chronologically.** Results follow on-chain storage order. Sort by `entry.id` for insertion order. - **zkLogin support is untested.** The decoder is included, but Walrus and Seal `SessionKey` flows have not been verified with zkLogin signatures. Use keypair or passkey accounts until you have tested your setup. > **WARNING: Encrypted entries are always created as drafts** > > Encrypted entries are always created as drafts. You must explicitly publish them with a separate transaction after adding. The Move contract hardcodes `encrypted=false` on `publish_from_draft` and `publish_direct`, so published revisions are always unencrypted and public. See [Entries → Adding an entry](https://docs.morsecms.xyz/entries.html#adding-an-entry) for the full explanation. ## Next steps - [SDK: Setup](https://docs.morsecms.xyz/sdk-client-setup.html): Install, wire a wallet, pick a Walrus adapter, handle errors. - [SDK: Cookbooks](https://docs.morsecms.xyz/sdk-workflows.html): Create publications and collections, add entries (plain + encrypted), read them back. --- # SDK Setup > Install `morse-sdk`, wire a wallet adapter (server or browser), pick a Walrus access pattern, and learn how to narrow errors. ## Compatibility `@arcadiasystems/morse-sdk` is built and tested against specific versions of its Mysten substrate. Newer or older versions are not validated and may produce runtime errors. The peer-dependency ranges in `package.json` enforce these bounds. Version 0.11.0 moved all three ranges, so upgrading from 0.10.x means upgrading the three Mysten packages with it. | Package | Version | Required | | --- | --- | --- | | `@arcadiasystems/morse-sdk` | 0.11.x | (this package) | | `@mysten/sui` | >=2.38.0 <2.39.0 | Yes: types, gRPC client, keypairs | | `@mysten/walrus` | >=1.2.37 <1.2.38 | Yes: default Walrus adapters (read + write) | | `@mysten/seal` | >=1.5.2 <1.5.3 | Yes: encryption | > **WARNING: Pin to the verified minor version** > > Mysten packages may ship breaking changes within the same major version. When a new version like `@mysten/walrus@1.3.0` is released, `morse-sdk` needs a coordinated bump and re-verification. Use `@arcadiasystems/morse-sdk@~0.11.0`, as in the [install command](#installing) below, to lock to patch-level updates only. The verified matrix is also exported programmatically as `TESTED_SUBSTRATE`, a frozen record of the tested `@mysten/sui`, `@mysten/seal`, and `@mysten/walrus` versions plus `verifiedOn` and `suiNetwork`. Log it during initialization or compare it against bundler-resolved versions in CI: ```typescript import { TESTED_SUBSTRATE } from "@arcadiasystems/morse-sdk"; console.log(TESTED_SUBSTRATE["@mysten/walrus"]); // "1.2.37" ``` ## Runtime requirements ESM-only (`"type": "module"`). CommonJS `require` is not supported. | Runtime | Supported | Notes | | --- | --- | --- | | Bun | ≥ 1.2.0 | Primary development runtime. End-to-end test scripts use Bun. | | Node | ≥ 18.0 | Uses ES2022 (private fields, `Error.cause`), `TextEncoder`, `crypto.getRandomValues`, `BigInt`. | | Browser | Evergreen | Chrome / Edge / Firefox / Safari recent stable. Vite, Webpack, esbuild bundlers all handle the SDK with zero polyfills. | The SDK works in both Node and browser environments. It does not depend on Node-specific APIs like `fs`, `path`, `process`, or `node:crypto`. Some `@mysten/*` packages may need polyfills in the browser. Check their docs if needed, though modern bundlers usually handle this automatically. ## Installing `@mysten/sui`, `@mysten/walrus`, and `@mysten/seal` are all required peer dependencies. The SDK's main entry imports the default Walrus and Seal adapters, so both packages must be installed even for workflows that never upload or encrypt. The `@mysten/*` peers are pinned to the exact versions the SDK accepts: it takes one `@mysten/walrus` and one `@mysten/seal` release, because a later patch of either can need a newer `@mysten/sui` than the SDK supports. ```bash bun add @arcadiasystems/morse-sdk@~0.11.0 @mysten/sui@2.38.0 @mysten/walrus@1.2.37 @mysten/seal@1.5.2 ``` ## morseConfig() shape `morseConfig({ network })` takes the target network (`"mainnet"`, `"testnet"`, or `"localnet"`) and returns a read-only object with the contract addresses and endpoints the SDK needs. All other fields have built-in defaults but can be overridden for custom deployments. Every SDK function takes this object as its `config` argument. The returned object contains the following fields: | Field | Type | Description | | --- | --- | --- | | `network` | `"mainnet" \| "testnet" \| "localnet"` | Sui network the config targets. | | `rpcUrl` | `string` | Sui fullnode URL to pass as `baseUrl` when you construct `SuiGrpcClient`. The SDK never reads it itself; its readers and adapters take the client you build. Defaults to the public fullnode for the chosen network; override for a custom node. | | `packageId` | `PackageId` | Current `published-at` address of the Morse Move package. Used as the target for all transaction calls. | | `originalPackageId` | `PackageId \| undefined` | Genesis publish address (`original-id`). Sui uses this in `objectType` strings and type filters. Defaults to `packageId` when the package has never been upgraded. | | `registryId` | `RegistryId` | Sui object ID of the shared `PublicationRegistry`. Required for publication creation and deletion. | | `sealKeyServers` | `readonly KeyServerConfig[]` | Seal threshold-encryption key servers for the network. Each entry has an `objectId` and `weight`. Used by `DefaultSealAdapter` when no explicit server list is provided. | | `walrusEndpoints` | `WalrusEndpoints` | Walrus HTTP endpoints. Contains `aggregator` (base URL for reads) and an optional `publisher` (base URL for HTTP-publisher writes). The publisher is intentionally unset by default; supply it explicitly for your operator. | The options shape is exported as `MorseConfigOptions`, the accepted network names as the `Network` constant, and the per-network public fullnode defaults as `DEFAULT_RPC_URLS` (useful when you need the default endpoint without building a full config). `PackageId` and `RegistryId` are branded `string` types: compile-time guards that prevent passing one kind of Sui address where another is expected. Cast a raw hex string with the `toPackageId()` / `toRegistryId()` codec helpers. For quilt-mode collections, the SDK also ships codecs for the 37-byte `QuiltPatchId` (`QUILT_PATCH_ID_LENGTH` = 37): `quiltPatchIdFromString` / `quiltPatchIdToString` convert between the URL-safe base64 string Walrus emits and the branded bytes, and `encodeQuiltPatchId` / `decodeQuiltPatchId` convert between the bytes and their structured parts (`quiltBlobId`, `version`, `startIndex`, `endIndex`; the `QuiltPatchIdParts` type). ## Server setup `RpcPublicationReader` provides read-only access to publications, collections, and entries via RPC. No keypair required. To set up a server-side client, create a testnet config, a gRPC client, a `KeypairAdapter` for signing transactions, and an `RpcPublicationReader` for querying on-chain data. ```typescript import { SuiGrpcClient } from "@mysten/sui/grpc"; import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519"; import { KeypairAdapter, morseConfig, RpcPublicationReader, } from "@arcadiasystems/morse-sdk"; const privateKey = process.env.PRIVATE_KEY!; // Bech32: "suiprivkey1..." const config = morseConfig({ network: "testnet" }); const client = new SuiGrpcClient({ network: "testnet", baseUrl: config.rpcUrl, }); const keypair = Ed25519Keypair.fromSecretKey(privateKey); const adapter = KeypairAdapter.fromSecretKey(privateKey, client); const reader = RpcPublicationReader.fromMorseConfig(config, client); ``` `PRIVATE_KEY` must be a Bech32 `suiprivkey1...` string. Export it from your wallet's settings. Hex keys are not supported by `Ed25519Keypair.fromSecretKey`. ## Browser setup For browser apps, the SDK connects to the user's wallet extension (e.g. Slush, Suiet) instead of `KeypairAdapter`. The private key stays in the wallet and is never exposed to the SDK. ### Providers To wire the providers, add `SuiClientProvider` and `WalletProvider` to the root of the React component tree. `@mysten/dapp-kit` also requires `@tanstack/react-query` as a peer dependency. ```tsx import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { SuiClientProvider, WalletProvider } from "@mysten/dapp-kit"; import { getFullnodeUrl } from "@mysten/sui/client"; import "@mysten/dapp-kit/dist/index.css"; const queryClient = new QueryClient(); const networks = { testnet: { url: getFullnodeUrl("testnet") } }; ``` ### useMorse hook Build the SDK context once per connected account. `WalletStandardSigner.fromAccount` wraps the account's public key and sign callbacks into a Sui `Signer`, so libraries like `@mysten/walrus` and `@mysten/seal` work without the SDK ever touching the user's key. > **WARNING: gRPC transport required** > > The SDK is typed against `SuiGrpcClient` from `@mysten/sui/grpc`, not the JSON-RPC `SuiClient` used by most Sui browser apps. `SuiGrpcClient` works in modern evergreen browsers without a proxy. The SDK makes no JSON-RPC calls, so environments that block gRPC must proxy it or use a gRPC endpoint they can reach. ```typescript import { useCurrentAccount, useSignAndExecuteTransaction, useSignPersonalMessage, useSignTransaction, } from "@mysten/dapp-kit"; import { SuiGrpcClient } from "@mysten/sui/grpc"; import { morseConfig, RpcPublicationReader, WalletStandardSigner, } from "@arcadiasystems/morse-sdk"; import { useMemo } from "react"; import { WalletStandardAdapter } from "./WalletStandardAdapter"; // ^ Create this file in your project - see full source below export function useMorse() { const account = useCurrentAccount(); const { mutateAsync: signAndExecute } = useSignAndExecuteTransaction(); const { mutateAsync: signTransaction } = useSignTransaction(); const { mutateAsync: signPersonalMessage } = useSignPersonalMessage(); return useMemo(() => { if (!account) return null; const config = morseConfig({ network: "testnet" }); const client = new SuiGrpcClient({ network: "testnet", baseUrl: config.rpcUrl, }); // For @arcadiasystems/morse-sdk ops (createPublication, addEntry, ...). const adapter = new WalletStandardAdapter( account.address, ({ transaction }) => signAndExecute({ transaction }), client, ); // For Walrus uploads and Seal SessionKey - both want a Sui `Signer`. // Decodes the scheme from the wallet's public key; refuses unsupported accounts. const signer = WalletStandardSigner.fromAccount(account, { signTransaction: ({ transaction }) => signTransaction({ transaction }), signPersonalMessage: ({ message }) => signPersonalMessage({ message }), }); const reader = RpcPublicationReader.fromMorseConfig(config, client); return { adapter, signer, reader, config, client }; }, [account, signAndExecute, signTransaction, signPersonalMessage]); } ``` The `WalletStandardAdapter` wraps a dapp-kit wallet connection into the adapter interface the SDK expects. It bridges wallet-standard signing to the SDK's `SuiAdapter`, so the SDK can build and execute transactions without ever holding the user's private key. Create a `WalletStandardAdapter.ts` file in your project with the following adapter code: ```walletstandardadapter.ts import { type ObjectReader, type SimulationReturnValues, type SuiAddress, type SuiObjectId, type TransactionExecutor, TransportError, type TxCreatedObject, type TxDeletedObject, type TxReceipt, toSuiAddress, toSuiObjectId, type WalletAdapter, } from "@arcadiasystems/morse-sdk"; import type { SuiClientTypes } from "@mysten/sui/client"; import type { Transaction } from "@mysten/sui/transactions"; export type WalletSignAndExecute = (input: { transaction: Transaction; }) => Promise<{ digest: string }>; export class WalletStandardAdapter implements WalletAdapter { readonly address: SuiAddress; constructor( address: string, private readonly signAndExecute: WalletSignAndExecute, private readonly client: TransactionExecutor, ) { this.address = toSuiAddress(address); } // Execute via wallet extension, then wait for finality and parse effects. async signAndExecuteTransaction( tx: Transaction, signal?: AbortSignal, ): Promise { let digest: string; try { const result = await this.signAndExecute({ transaction: tx }); digest = result.digest; } catch (cause) { throw new TransportError( `wallet sign-and-execute failed: ${cause instanceof Error ? cause.message : String(cause)}`, { cause }, ); } const final = await this.client.waitForTransaction({ digest, include: { effects: true, objectTypes: true }, ...(signal === undefined ? {} : { signal }), }); if (final.$kind === "FailedTransaction") { throw new TransportError( failedTransactionMessage(final.FailedTransaction), ); } return parseTxReceipt(final.Transaction); } // Dry-run the transaction to get return values without executing. async simulateTransaction( tx: Transaction, signal?: AbortSignal, ): Promise { tx.setSenderIfNotSet(this.address); const result = await this.client.simulateTransaction({ transaction: tx, include: { effects: true, commandResults: true }, ...(signal === undefined ? {} : { signal }), }); if (result.$kind === "FailedTransaction") { throw new TransportError( failedTransactionMessage(result.FailedTransaction), ); } const commandResults = result.commandResults ?? []; return commandResults.map((cmd) => cmd.returnValues.map((rv) => rv.bcs)); } } // Convert raw transaction effects into the SDK's TxReceipt shape. function parseTxReceipt( tx: SuiClientTypes.Transaction<{ effects: true; objectTypes: true }>, ): TxReceipt { const effects = tx.effects; if (!effects) { throw new TransportError("transaction effects missing despite include flag"); } const objectTypes = tx.objectTypes ?? {}; return { digest: tx.digest, gasUsedMist: computeGasUsedMist(effects.gasUsed), createdObjects: collectCreated(effects.changedObjects, objectTypes), deletedObjects: collectDeleted(effects.changedObjects), }; } // Net gas = computation + storage - rebate (in MIST). function computeGasUsedMist(gas: SuiClientTypes.GasCostSummary): bigint { return ( BigInt(gas.computationCost) + BigInt(gas.storageCost) - BigInt(gas.storageRebate) ); } // Filter changed objects for newly created ones and attach their types. function collectCreated( changes: readonly SuiClientTypes.ChangedObject[], objectTypes: Record, ): TxCreatedObject[] { const out: TxCreatedObject[] = []; for (const change of changes) { if (change.idOperation !== "Created") continue; const objectType = objectTypes[change.objectId]; if (objectType === undefined) continue; out.push({ objectId: toSuiObjectId(change.objectId), objectType }); } return out; } // Filter changed objects for deleted ones. function collectDeleted( changes: readonly SuiClientTypes.ChangedObject[], ): TxDeletedObject[] { const out: TxDeletedObject[] = []; for (const change of changes) { if (change.idOperation !== "Deleted") continue; out.push({ objectId: toSuiObjectId(change.objectId) }); } return out; } function failedTransactionMessage( failed: SuiClientTypes.Transaction<{ effects: true }>, ): string { const error = failed.effects?.status?.error; if (error) { return `transaction failed: ${error.message}`; } return "transaction failed without effects status"; } ``` Slush, Suiet, and Sui Wallet (Ed25519) are verified. Secp256k1, Secp256r1, and Passkey decoders ship but Walrus + Seal end-to-end behavior is unverified. Fall back to Ed25519 if you see scheme-specific errors. ZkLogin is decoded but unverified; MultiSig is refused with `ConfigurationError` at `fromAccount` time. Wrap the call in try/catch and render unsupported accounts as product copy. See the full table in the [SDK README compatibility section](https://github.com/arcadiasystems/morse-dcms/blob/main/morse-sdk/README.md#wallet-scheme-support). ## Picking a Walrus adapter The SDK ships two Walrus read adapters and two write adapters with different trade-offs. Read and write adapters can be mixed independently; for example, `DefaultWalrusWriteAdapter` (for the `addEntryFromBytes` optimization) with `HttpAggregatorReadAdapter` (for reliable testnet reads). | Pair | Trust | Transactions (upload + addEntry) | Storage paid by | Browser CORS | | --- | --- | --- | --- | --- | | `DefaultWalrusReadAdapter` + `DefaultWalrusWriteAdapter` | Trustless (direct fanout to ~30 storage nodes) | **2** with `addEntryFromBytes`, **3** if split | Consumer wallet (WAL + gas) | Spotty on testnet | | `HttpAggregatorReadAdapter` + `HttpPublisherWriteAdapter` | Operator-trusted | **1** (publisher uploads server-side; consumer only signs the `addEntry` transaction) | Publisher operator (WAL); consumer pays Sui gas only | Reliable (one endpoint) | ### Default adapter Best for CLI scripts, server-side dapps, and browser dapps that do not hit testnet CORS gaps. The flow-aware optimization (`addEntryFromBytes`) cuts transactions from 3 to 2. ```typescript import { DefaultWalrusReadAdapter, DefaultWalrusWriteAdapter, } from "@arcadiasystems/morse-sdk"; const walrusReader = DefaultWalrusReadAdapter.fromConfig({ network: "testnet", suiClient: client, }); const walrusWriter = DefaultWalrusWriteAdapter.fromConfig( { network: "testnet", suiClient: client }, keypair, // or `signer` in the browser ); ``` ### HTTP adapter Best for browser dapps that need reliable CORS-friendly reads (one endpoint instead of ~30 nodes) and a "publisher pays storage" model where the user only signs the on-chain `addEntry`. Reads go through a trusted operator. To trust-but-verify, pass a `verifyBlobIntegrity` callback (type `WalrusBlobIntegrityCheck`) as a construction option. The adapter invokes it with the fetched bytes and the expected blob ID after every successful blob fetch, and it must throw on a mismatch (rewrapped as `TransportError`). Quilt patch reads are not verified. ```typescript import { HttpAggregatorReadAdapter, HttpPublisherWriteAdapter, } from "@arcadiasystems/morse-sdk"; const walrusReader = HttpAggregatorReadAdapter.fromMorseConfig(config, client, { // Optional trust-but-verify: runs after every blob fetch; throw on mismatch. verifyBlobIntegrity: async (bytes, expectedBlobId) => { // e.g. recompute via WalrusClient.computeBlobMetadata and compare blob IDs }, }); const walrusWriter = HttpPublisherWriteAdapter.fromConfig({ publisherUrl: "https://walrus-testnet-publisher.nami.cloud", ownerAddress: account.address, }); ``` > **WARNING: HTTP adapters skip the 2-transaction optimization** > > The HTTP adapters are NOT compatible with `addEntryFromBytes` / `addEncryptedEntryFromBytes`. Those functions require `WalrusFlowCapable` for the combined PTB; the publisher-paid path is naturally 1 transaction through `uploadBlob` + `addEntry`. Check any `WalrusWriteAdapter` at runtime with the `isWalrusFlowCapable(walrus)` type guard. ## Verifying setup After completing either the server or browser setup above, confirm your connection works by listing publications owned by your address: ```typescript const { results } = await reader.listPublicationsOwnedBy(adapter.address); console.log(`Connected! Found ${results.length} publication(s).`); ``` A successful call confirms the client, config, and RPC endpoint are wired correctly, even if the count is 0. Follow the [SDK Cookbooks](https://docs.morsecms.xyz/sdk-workflows.html) to create your first publication. ## Error handling Every error thrown by the SDK extends `MorseError`. Each error class carries structured properties, so errors can be handled by type without parsing message strings. All error classes are listed below; the first four are the most common. | Class | Carries | Thrown when | | --- | --- | --- | | `ValidationError` | `field` | Client-side input failed a precondition. | | `ContractAbortError` | `module`, `abortCode`, `reason` | Move VM aborted (e.g. `ESlugAlreadyExists`, `EPublisherCapRevoked`). | | `TransportError` | `operation?` | RPC, network, or response-parsing failure. Usually retryable. | | `UncertifiedBlobError` | `blobObjectId`, `blobId` | `addEntryFromBytes` upload succeeded but the second transaction failed; blob is on Walrus but unattached. | | `NotFoundError` | `resource`, `identifier` | Object does not exist on-chain. For `resource: "blob"`, Walrus has no stored copy of the content, and the subclass `BlobExpiredError` means its storage lease has ended. | | `UnauthorizedError` | | Client-side auth check failed before submitting the transaction. | | `SealError` | `code` | Seal authorization or decryption failed. Codes: `no-access`, `decrypt-failed`, `session-expired`, `rate-limited`. | | `ConfigurationError` | | SDK config issue, e.g. unsupported network. | | `UnsupportedWalletSchemeError` | `code`, `publicKeyBytes`, `address`, `walletName?` | `WalletStandardSigner.fromAccount` rejected the account's public key shape or the recovery flow could not derive a matching address. | To catch and inspect errors by type: ```typescript import { ContractAbortError, TransportError, UncertifiedBlobError, ValidationError, } from "@arcadiasystems/morse-sdk"; try { await quickstart(privateKey); } catch (err) { if (err instanceof UncertifiedBlobError) { console.error( `uncertified blob ${err.blobObjectId} (id ${err.blobId}); upload succeeded but the entry was not created`, ); } else if (err instanceof ContractAbortError) { console.error(`move abort: ${err.module}::${err.reason}`); } else if (err instanceof ValidationError) { console.error(`bad input on ${err.field}: ${err.message}`); } else if (err instanceof TransportError) { console.error(`transport: ${err.message}`); } else { throw err; } } ``` ### Formatting errors for UI For end-user surfaces (toasts, dialogs, banners), `formatUserMessage(err)` translates any thrown value into a `FormattedError`: `{ title, description, cause }`. It accepts `unknown`, so it works directly in a catch block without narrowing first; non-`MorseError` throws fall back to a generic message, and the original error is preserved in `cause`. ```typescript import { formatUserMessage } from "@arcadiasystems/morse-sdk"; try { await addEntryFromBytes(adapter, config, args); } catch (err) { const { title, description } = formatUserMessage(err); toast.error(title, { description }); // e.g. "Slug already taken" } ``` The copy uses the protocol's own terminology ("publication", "entry", "PublisherCap"). To translate into your app's domain (blog → post, gallery → image), narrow on the error class first and write your own message, then fall back to `formatUserMessage` for the rest. ## Next steps - [SDK: Cookbooks](https://docs.morsecms.xyz/sdk-workflows.html): Create publications, add entries (plain + encrypted), read them back. - [Quick Start: SDK](https://docs.morsecms.xyz/quick-start-sdk.html): End-to-end script: create a publication, add an entry, and read it back. --- # SDK Cookbooks > End-to-end recipes for `morse-sdk`: publish content, revise entries, encrypt and decrypt, delegate write access, and tear down resources. For installation and adapter wiring, see [SDK Setup](https://docs.morsecms.xyz/sdk-client-setup.html). ## Prerequisites | Requirement | Details | Purpose | | --- | --- | --- | | Bun or Node | v1.2+ / ≥ 18 | JavaScript runtime | | SUI + WAL tokens | [SUI faucet](https://faucet.sui.io/) · [Swap SUI for WAL](https://stake-wal.wal.app/?network=testnet) | Gas fees (SUI) and Walrus storage fees (WAL) | | Bech32 private key | A `suiprivkey1...` string | SDK authentication. Export from your wallet's settings | Make sure the SDK is installed and configured before continuing. See [SDK Setup](https://docs.morsecms.xyz/sdk-client-setup.html) for details. ## Lifecycle Publications hold collections; collections hold entries; entries hold an append-only vector of revisions. Permission management lives in Sui; payloads live in Walrus. The examples below assume you have built a context as shown in [SDK Setup](https://docs.morsecms.xyz/sdk-client-setup.html). Browser flows carry a `signer` (via `WalletStandardSigner.fromAccount`) instead of `keypair`. Walrus adapters (`walrusWriter`, `walrusReader`) are constructed where needed. See [Picking a Walrus Adapter](https://docs.morsecms.xyz/sdk-client-setup.html#picking-a-walrus-adapter) for options. ## Creating a publication Returns the publication's ID along with an `OwnerCap` for governance and a `PublisherCap` for write access, both transferred to the caller's address atomically. ```typescript import { ContractAbortError, createPublication } from "@arcadiasystems/morse-sdk"; try { const created = await createPublication(adapter, config, { name: "My Publication", slug: "my-publication", }); // created.publicationId, created.ownerCapId, created.publisherCapId } catch (err) { if (err instanceof ContractAbortError && err.reason === "ESlugAlreadyExists") { // slug is globally unique - pick another or look up the existing publication } throw err; } ``` ## Adding a collection `storageMode` is fixed at creation; switching the mode for the same collection name requires deleting and recreating. ```typescript import { createCollection } from "@arcadiasystems/morse-sdk"; await createCollection(adapter, config, { publicationId: created.publicationId, publisherCapId: created.publisherCapId, name: "blog", storageMode: "blob", // or "quilt" for packed small payloads }); ``` ## Adding an entry Uploads bytes to Walrus and attaches them as the first revision of a new entry in 2 transactions. Returns the assigned `entryId` (a monotonic `u64`) used to reference the entry in all subsequent operations. ```typescript import { addEntryFromBytes, DefaultWalrusWriteAdapter, } from "@arcadiasystems/morse-sdk"; const walrusWriter = DefaultWalrusWriteAdapter.fromConfig( { network: "testnet", suiClient: client }, keypair, // or `signer` in the browser ); const entry = await addEntryFromBytes(adapter, config, { walrus: walrusWriter, publicationId: created.publicationId, publisherCapId: created.publisherCapId, collectionName: "blog", name: "first-post", bytes: new TextEncoder().encode("hello world"), contentType: "text/plain", upload: { epochs: 3, deletable: true }, }); // entry.entryId is the stable monotonic ID (0, 1, 2, ...) ``` `addEntryFromBytes` uses the Walrus flow-aware capability to combine `certify_blob` and `add_entry_to_collection` into a single PTB, reducing the total from 3 transactions to 2. The HTTP publisher path collapses this further to 1 transaction. > **WARNING: Handle `UncertifiedBlobError`** > > If transaction 1 (register + upload) succeeds but transaction 2 (certify + add_entry) fails, the SDK throws `UncertifiedBlobError` carrying `blobObjectId` and `blobId`. The blob exists on storage nodes but remains uncertified and is released when the registration expires. Log the IDs or surface the error for debugging. See also: [CLI Cookbooks → Publishing your first entry](https://docs.morsecms.xyz/cli-workflows.html#publishing-your-first-entry) ## Encrypted content Encrypts content with Seal, uploads ciphertext to Walrus, and attaches it as the first revision of an encrypted entry in 2 transactions. Returns the same `entryId` as the plaintext path. Encrypted entries are always created as drafts and accessed via Seal decryption, not the public read path. ```typescript import { addEncryptedEntryFromBytes, buildPublisherSealId, createCollection, DefaultSealAdapter, DefaultWalrusWriteAdapter, } from "@arcadiasystems/morse-sdk"; // Create a blob-mode collection for encrypted content. await createCollection(adapter, config, { publicationId: created.publicationId, publisherCapId: created.publisherCapId, name: "secret-blog", storageMode: "blob", }); const seal = DefaultSealAdapter.fromMorseConfig(config, {}, client); const walrusWriter = DefaultWalrusWriteAdapter.fromConfig( { network: "testnet", suiClient: client }, keypair, ); // Fresh per-entry identity. publicId(32) || tag(1=Publisher) || nonce. const nonce = crypto.getRandomValues(new Uint8Array(16)); const sealId = buildPublisherSealId(created.publicationId, nonce); const plaintext = new TextEncoder().encode("secret content"); const result = await addEncryptedEntryFromBytes(adapter, config, { walrus: walrusWriter, seal, publicationId: created.publicationId, publisherCapId: created.publisherCapId, collectionName: "secret-blog", name: "private-doc", plaintext, contentType: "application/octet-stream", sealId, upload: { epochs: 3, deletable: true }, }); ``` `buildPublisherSealId` constructs the Seal identity from the publication ID and a random nonce. Each entry needs a fresh nonce; reusing a nonce produces the same identity. When creating a `SessionKey`, always pass `config.originalPackageId ?? config.packageId` as the package ID. Seal uses the original (genesis) package address, not the current upgrade address. > **WARNING: Encrypted entries are always created as drafts** > > `publishFromDraft` and `publishDirect` always write non-encrypted revisions; the contract hardcodes `encrypted=false` on the public path. For encrypted content, use `addEncryptedEntryFromBytes` / `appendEncryptedDraftRevision` and read via `seal.decrypt`. ### Reading encrypted entries The full read flow for encrypted content combines entry metadata lookup, Walrus blob fetch, and Seal decryption. Decryption requires three things: a Seal `SessionKey` (built via wallet personal-message signature), the `sealId` bound to the revision, and a non-revoked `PublisherCap` ID. ```typescript import { SessionKey } from "@mysten/seal"; import { DefaultSealAdapter, HttpAggregatorReadAdapter } from "@arcadiasystems/morse-sdk"; const seal = DefaultSealAdapter.fromMorseConfig(config, {}, client); const walrusReader = HttpAggregatorReadAdapter.fromMorseConfig(config, client); // 1. Read entry metadata to get the draft revision. const fetched = await reader.getEntry( created.publicationId, "secret-blog", result.entryId, ); const revision = fetched.revisions[fetched.draftHead]; // 2. Extract the blobRef and sealId from the revision. const blobRef = revision.blobRef; // { kind: "blob", blobObjectId: "0x..." } const sealId = revision.sealId; // Uint8Array // 3. Fetch ciphertext from Walrus. const ciphertext = await walrusReader.readBlobRef(blobRef); // 4. Create a SessionKey and decrypt with Seal. const sessionKey = await SessionKey.create({ address: keypair.toSuiAddress(), packageId: config.originalPackageId ?? config.packageId, ttlMin: 10, signer: keypair, // or `signer` in the browser suiClient: client, }); const plaintext = await seal.decrypt(ciphertext, { sessionKey, sealId, publisherCapId: created.publisherCapId, }); console.log(new TextDecoder().decode(plaintext)); ``` ### Encrypted draft revisions After creating an encrypted entry with `addEncryptedEntryFromBytes`, use `appendEncryptedDraftRevision` to add subsequent encrypted revisions to the same entry. You encrypt and upload the blob yourself, then pass the `blobObjectId` to the SDK. ```typescript import { appendEncryptedDraftRevision, buildPublisherSealId, DefaultSealAdapter, DefaultWalrusWriteAdapter, } from "@arcadiasystems/morse-sdk"; const seal = DefaultSealAdapter.fromMorseConfig(config, {}, client); const walrusWriter = DefaultWalrusWriteAdapter.fromConfig( { network: "testnet", suiClient: client }, keypair, ); // 1. Build a fresh Seal identity for the new revision. const nonce = crypto.getRandomValues(new Uint8Array(16)); const sealId = buildPublisherSealId(created.publicationId, nonce); // 2. Encrypt the updated content and upload to Walrus. const updatedPlaintext = new TextEncoder().encode("updated secret content"); const { ciphertext } = await seal.encrypt(updatedPlaintext, { sealId }); const blob = await walrusWriter.uploadBlob(ciphertext, { epochs: 3, deletable: true, }); // 3. Append the encrypted revision to the existing entry. const revision = await appendEncryptedDraftRevision(adapter, config, { publicationId: created.publicationId, publisherCapId: created.publisherCapId, collectionName: "secret-blog", entryId: result.entryId, // from addEncryptedEntryFromBytes blobObjectId: blob.blobObjectId, contentType: "application/octet-stream", sealId, }); console.log("Revision", revision.revisionId, "appended"); ``` See also: [CLI Cookbooks → Encrypted content](https://docs.morsecms.xyz/cli-workflows.html#encrypted-content) ## Reading it back Single-object reads, paginated lists, and an async iterator for full-collection scans. The reader is gRPC-backed. ```typescript import { HttpAggregatorReadAdapter } from "@arcadiasystems/morse-sdk"; const walrusReader = HttpAggregatorReadAdapter.fromMorseConfig(config, client); // Single entry by ID. const fetched = await reader.getEntry( created.publicationId, "blog", entry.entryId, ); const firstRevision = fetched.revisions[0]; // Fetch the actual content bytes from Walrus. const content = await walrusReader.readBlobRef(firstRevision.blobRef); console.log(new TextDecoder().decode(content)); // Paginated list. const page = await reader.listEntries( created.publicationId, "blog", { limit: 50 }, ); console.log(page.results.length, "entries, next cursor:", page.nextCursor); // Stream every entry in a collection. for await (const e of reader.scanEntries(created.publicationId, "blog")) { console.log(e.id, e.name); } // Single revision by entry ID and revision index. const revision = await reader.getRevision( created.publicationId, "blog", entry.entryId, 0, ); ``` > **INFO: Ordering caveat** > > `listEntries` and `scanEntries` return entries in dynamic-field object-store order, not chronological order. Sort by `entry.id` if you need insertion order. ### Publication and cap lookups Beyond entries, the reader resolves publications and the caps that control them, useful for dashboards ("which publications does this wallet own?") and for verifying delegated write access: ```typescript // Publication metadata: name, slug, collections. const publication = await reader.getPublication(created.publicationId); // Publications owned (via OwnerCap) by an address. const owned = await reader.listPublicationsOwnedBy(adapter.address); // owned.results: { ownerCapId, publicationId } handles (OwnedPublication) // PublisherCaps held by an address, and a single cap by ID. const caps = await reader.listPublisherCapsOwnedBy(adapter.address); const cap = await reader.getPublisherCap(created.publisherCapId); // cap.publicationId, cap.holder ``` All list methods return one page per call (`EntryListPage`, `PublicationListPage`, and `PublisherCapListPage`), each with a `results` array and a `nextCursor` (`null` when exhausted). They accept `{ limit, cursor, signal }` options (default limit 50; the cursor is opaque: feed `nextCursor` back in to get the next page). `scanEntries` auto-paginates and takes `{ pageSize, signal }` instead. Single-object reads (`getPublication`, `getPublisherCap`, `getEntry`, `getRevision`) throw `NotFoundError` when the target does not exist. See also: [CLI Cookbooks → Reading it back](https://docs.morsecms.xyz/cli-workflows.html#reading-it-back) ## Publishing a new revision Append a new public revision directly, or stage a draft first and promote it. Revisions are append-only; existing revisions are never edited in place. Two heads track progress: `draftHead` (work in progress) and `publicHead` (the live version). ### Option A: publish direct Skip the draft stage and append a public revision in one step. Requires a pre-uploaded blob (use `uploadBlob` first). ```typescript import { publishDirect, DefaultWalrusWriteAdapter } from "@arcadiasystems/morse-sdk"; const walrusWriter = DefaultWalrusWriteAdapter.fromConfig( { network: "testnet", suiClient: client }, keypair, ); const blob = await walrusWriter.uploadBlob( new TextEncoder().encode("updated content"), { epochs: 3, deletable: true }, ); const result = await publishDirect(adapter, config, { publicationId: created.publicationId, publisherCapId: created.publisherCapId, collectionName: "blog", entryId: entry.entryId, blobObjectId: blob.blobObjectId, contentType: "text/plain", }); // result.revisionId is the index of the new public revision ``` ### Option B: draft then publish Stage a draft revision (updates `draftHead` only), iterate on it, then promote to a public revision. Both steps require a pre-uploaded blob (use `uploadBlob` first). The promotion appends a fresh public revision; the draft blob is referenced for validation but not reused. ```typescript import { appendDraftRevision, publishFromDraft, DefaultWalrusWriteAdapter } from "@arcadiasystems/morse-sdk"; const walrusWriter = DefaultWalrusWriteAdapter.fromConfig( { network: "testnet", suiClient: client }, keypair, ); // 1. Upload the draft content const draftBlob = await walrusWriter.uploadBlob( new TextEncoder().encode("draft content"), { epochs: 3, deletable: true }, ); // 2. Stage a draft revision const draft = await appendDraftRevision(adapter, config, { publicationId: created.publicationId, publisherCapId: created.publisherCapId, collectionName: "blog", entryId: entry.entryId, blobObjectId: draftBlob.blobObjectId, contentType: "text/plain", }); // 3. Upload the final content and promote the draft const finalBlob = await walrusWriter.uploadBlob( new TextEncoder().encode("final content"), { epochs: 3, deletable: true }, ); const published = await publishFromDraft(adapter, config, { publicationId: created.publicationId, publisherCapId: created.publisherCapId, collectionName: "blog", entryId: entry.entryId, draftRevisionId: draft.revisionId, blobObjectId: finalBlob.blobObjectId, contentType: "text/plain", }); ``` See also: [CLI Cookbooks → Publishing a new revision](https://docs.morsecms.xyz/cli-workflows.html#publishing-a-new-revision) ## Delegating write access Morse separates ownership (`OwnerCap`) from publishing (`PublisherCap`). The owner issues a cap bound to a co-author's address, granting them write access. Once issued, the co-author can publish entries using their own key. Revoking the cap causes further writes to abort on-chain. See [Access Control → Issuing publisher caps](https://docs.morsecms.xyz/access-control.html#issuing-publisher-caps) for details. ```typescript import { issuePublisherCap, revokePublisherCap, destroyPublisherCap, } from "@arcadiasystems/morse-sdk"; // Owner issues a cap bound to a co-author's address const issued = await issuePublisherCap(adapter, config, { publicationId: created.publicationId, ownerCapId: created.ownerCapId, holder: "0xCO_AUTHOR_ADDRESS", }); // issued.publisherCapId // Owner revokes; further writes with this cap abort with EPublisherCapRevoked await revokePublisherCap(adapter, config, { publicationId: created.publicationId, ownerCapId: created.ownerCapId, publisherCapId: issued.publisherCapId, }); // Co-author destroys their own cap (requires an adapter built from the co-author's keypair; see SDK Setup) await destroyPublisherCap(coAuthorAdapter, config, { publicationId: created.publicationId, publisherCapId: issued.publisherCapId, }); ``` > **WARNING: Holder binding is immutable** > > A PublisherCap is bound to the `holder` address at issue time. Transferring the Sui object to a different address does NOT grant write rights. To rotate authority to a new address, destroy the old cap and issue a fresh one. See also: [CLI Cookbooks → Delegating write access](https://docs.morsecms.xyz/cli-workflows.html#delegating-write-access) ## Transferring ownership Transfer the `OwnerCap` to a new address. The recipient gains governance rights: issuing/revoking publisher caps and deleting the publication. Existing publisher caps remain usable until individually revoked. ```typescript import { transferOwnership } from "@arcadiasystems/morse-sdk"; await transferOwnership(adapter, config, { ownerCapId: created.ownerCapId, recipient: "0xNEW_OWNER_ADDRESS", }); ``` > **WARNING: One-way for the sender** > > Once transferred, the original owner loses all governance rights immediately. The new owner could transfer back, but there is no built-in undo. See also: [CLI Cookbooks → Transferring ownership](https://docs.morsecms.xyz/cli-workflows.html#transferring-ownership) ## Deleting and tearing down Deletion works bottom-up: remove entries first, then the collections, then the publication. The Move layer enforces this: deleting a non-empty collection or publication aborts on-chain. This assumes the caller still holds both a `PublisherCap` and the `OwnerCap`. Entry IDs are permanently retired on deletion and never reused. See [Entries → Deleting an entry](https://docs.morsecms.xyz/entries.html#deleting-an-entry) for details. ```typescript import { deleteEntry, deleteCollection, deletePublication, } from "@arcadiasystems/morse-sdk"; // 1. Remove all entries from each collection await deleteEntry(adapter, config, { publicationId: created.publicationId, publisherCapId: created.publisherCapId, collectionName: "blog", entryId: entry.entryId, }); await deleteEntry(adapter, config, { publicationId: created.publicationId, publisherCapId: created.publisherCapId, collectionName: "secret-blog", entryId: result.entryId, }); // 2. Remove the empty collections await deleteCollection(adapter, config, { publicationId: created.publicationId, publisherCapId: created.publisherCapId, name: "blog", }); await deleteCollection(adapter, config, { publicationId: created.publicationId, publisherCapId: created.publisherCapId, name: "secret-blog", }); // 3. Delete the publication (requires OwnerCap; reader verifies it's empty) await deletePublication(reader, adapter, config, { publicationId: created.publicationId, ownerCapId: created.ownerCapId, }); ``` See also: [CLI Cookbooks → Deleting and tearing down](https://docs.morsecms.xyz/cli-workflows.html#deleting-and-tearing-down) ## Entry path options The SDK provides two ways to publish content. `addEntryFromBytes` (and `addEncryptedEntryFromBytes` for encrypted content) handles upload and on-chain registration in one call and is the recommended default. For cases that need more control, use the split form: `uploadBlob` followed by `addEntry`. | Use case | Function | Transactions | | --- | --- | --- | | Publish raw bytes as a new entry (typical case) | `addEntryFromBytes` | 2 | | Publish encrypted bytes as a new entry | `addEncryptedEntryFromBytes` | 2 | | Reuse one blob across many entries (deduplication) | `uploadBlob` once, then `addEntry` N times | 2 + N | | Decouple upload and add-entry (draft-then-attach UX) | `uploadBlob` at upload time, `addEntry` at publish time | 2 + 1 | | Server pre-uploads; browser only adds entries | `uploadBlob` (server), `addEntry` (browser) | 2 + 1 | ### Split upload path When you need control over the upload step (for example, to retry blob registration independently or reuse a blob across entries), split the operation into two calls: ```ts import { addEntry, DefaultWalrusWriteAdapter } from "@arcadiasystems/morse-sdk"; const walrusWriter = DefaultWalrusWriteAdapter.fromConfig( { network: "testnet", suiClient: client }, keypair, ); const content = new TextEncoder().encode("hello world"); // 1. Upload blob to Walrus (2 wallet popups: register + certify). const blob = await walrusWriter.uploadBlob(content, { epochs: 3, deletable: true, }); // 2. Reference the blob in a new entry (1 wallet popup). const entry = await addEntry(adapter, config, { publicationId: created.publicationId, publisherCapId: created.publisherCapId, collectionName: "blog", name: "first-post", blobObjectId: blob.blobObjectId, contentType: "text/plain", }); ``` `addEntryFromBytes` requires an adapter that implements `WalrusFlowCapable`, which combines the certify and add-entry steps into a single transaction. `DefaultWalrusWriteAdapter` supports this; the HTTP publisher adapter does not. Passing an incompatible adapter throws a `TransportError` before any I/O. Use the split path (`uploadBlob` + `addEntry`) instead. To branch at runtime, use the `isWalrusFlowCapable(walrusWriter)` type guard, which detects the capability on any `WalrusWriteAdapter`. ## Next steps - [CLI: Overview](https://docs.morsecms.xyz/cli-overview.html): Interactive command-line tool for managing publications, collections, and entries without writing code. - [Smart Contracts: Overview](https://docs.morsecms.xyz/contracts-overview.html): Architecture of the Morse Move package: modules, shared objects, and the on-chain data model. --- # React Uploader > `@arcadiasystems/morse-uploader` gives React apps drop-in components and hooks for Morse recipient files. Upload a file to Walrus, optionally encrypt it for a list of wallets, and share a link. The connected wallet pays for and signs everything. > **WARNING: Network status** > > Morse runs on **Walrus Mainnet and testnet**. On mainnet, gas and Walrus storage cost real SUI and WAL, and there is no faucet. Test on testnet first, and read [Walrus Mainnet](https://docs.morsecms.xyz/mainnet.html) before you switch. Sui and Walrus testnets reset periodically (Walrus wipes every few months), so stored content and object IDs are lost on reset. Do not publish anything you cannot afford to lose. ## What it is The package wraps the [recipient file](https://docs.morsecms.xyz/recipient-files.html) flow of `morse-sdk` 0.9 or 0.10 for React. Each upload creates a `RecipientFile`: a Walrus blob plus an on-chain record that carries its own recipient set. Every step is a headless hook, and an optional set of styled components sits on top. - **Headless hooks**: One hook per step: setup, upload, download, decrypt, file metadata and storage cost. - **Drop-in components**: MorseFileUploader and MorseFileDownloader, themed with CSS variables. - **Two wallet popups**: Any upload, public or encrypted, takes two wallet popups, whatever the number of recipients. - **Share links**: A link carries the file ID, the network and the Seal material in the URL fragment, so none of it reaches a server log. - **Your wallet stack**: You pass in the network and the wallet callbacks. Wallet connection stays in your app. The package is ESM only and has four entry points: | Import path | Contents | | --- | --- | | `@arcadiasystems/morse-uploader` | The setup hook, the provider, the headless hooks, and the share-link and cost helpers. | | `@arcadiasystems/morse-uploader/ui` | The styled components: `MorseFileUploader` and `MorseFileDownloader`. | | `@arcadiasystems/morse-uploader/styles.css` | The stylesheet for the styled components. | | `@arcadiasystems/morse-uploader/wallet-standard` | `WalletStandardAdapter`, an optional browser `WalletAdapter` backed by a sign-and-execute callback. | ## Install The package is on npm as [`@arcadiasystems/morse-uploader`](https://www.npmjs.com/package/@arcadiasystems/morse-uploader). This page covers version 0.4.2. Install it with its peer dependencies: ```bash npm install @arcadiasystems/morse-uploader@~0.4.2 @arcadiasystems/morse-sdk@~0.10.1 @mysten/sui@2.36.0 @mysten/walrus@1.2.35 @mysten/seal@1.5.0 ``` The `@mysten/*` peers are pinned to exact versions because newer `@mysten/seal` and `@mysten/walrus` patch releases need a newer `@mysten/sui` than the SDK supports. Uploader apps stay on morse-sdk 0.10.1 and the Mysten versions above until morse-uploader 0.5.0, because 0.4.2 does not accept morse-sdk 0.11. Keep the peers inside the ranges below, so your app does not end up with two copies of the Sui, Walrus or Seal packages. A tilde range such as `~0.10.1` means `>=0.10.1 <0.11.0`, so the command above stays inside them. React and React DOM are assumed to be in your app already. | Peer dependency | Range | | --- | --- | | `@arcadiasystems/morse-sdk` | >=0.9.0 <0.11.0 | | `@mysten/sui` | >=2.36.0 <2.37.0 | | `@mysten/walrus` | >=1.2.35 <1.2.36 | | `@mysten/seal` | >=1.5.0 <1.5.1 | | `react`, `react-dom` | >=18.0.0 | The package marks `@mysten/walrus` and `@mysten/seal` as optional peers, but `morse-sdk` requires all three Mysten packages. Install them all. ## Provider and setup Wallet connection is your app's job. The examples here use `@mysten/dapp-kit`. Once a wallet is connected, `useMorseFiles` turns it into the SDK handles that every other hook and component reads: a wallet adapter, a signer, the Walrus read and write adapters, the Seal adapter and a file reader. When the handles are ready, wrap your tree in `MorseFilesProvider`. ```tsx import type { WalletStandardAccount } from "@arcadiasystems/morse-sdk"; import { MorseFilesProvider, useMorseFiles } from "@arcadiasystems/morse-uploader"; import { useSignAndExecuteTransaction, useSignPersonalMessage, useSignTransaction, } from "@mysten/dapp-kit"; import walrusWasmUrl from "@mysten/walrus-wasm/web/walrus_wasm_bg.wasm?url"; import type { ReactNode } from "react"; // Mount only while a wallet is connected, e.g. with dapp-kit's useCurrentAccount(). export function MorseFiles({ account, children, }: { account: WalletStandardAccount; children: ReactNode; }) { const { mutateAsync: signTransaction } = useSignTransaction(); const { mutateAsync: signPersonalMessage } = useSignPersonalMessage(); const { mutateAsync: signAndExecuteTransaction } = useSignAndExecuteTransaction(); const setup = useMorseFiles({ network: "testnet", account, callbacks: { signTransaction: ({ transaction }) => signTransaction({ transaction }), signPersonalMessage: ({ message }) => signPersonalMessage({ message }), signAndExecuteTransaction: ({ transaction }) => signAndExecuteTransaction({ transaction }), }, walrusWriteConfig: { wasmUrl: walrusWasmUrl, uploadRelay: { host: "https://upload-relay.testnet.walrus.space", sendTip: { max: 10_000_000 }, // MIST; see "Walrus upload relay" }, }, }); if (setup.status === "error") { return

Could not prepare your wallet.

; } if (setup.status !== "ready" || setup.handles === null) { return

Preparing your wallet...

; } return {children}; } ``` `useMorseFiles` returns `{ status, handles, error }`. `status` moves from `"idle"` to `"preparing"`, then to `"ready"` or `"error"`. The hook rebuilds only when the network or the wallet address changes, so passing new callback objects on every render is safe. ### Options | Option | Type | Description | | --- | --- | --- | | `network` | `"testnet" \| "mainnet"` | The network to build for. Required. | | `account` | `WalletStandardAccount` | The connected wallet account. Required. | | `callbacks` | `MorseFilesWalletCallbacks` | `signTransaction`, `signPersonalMessage` and `signAndExecuteTransaction` from your wallet. Required. | | `suiClient` | `SuiGrpcClient` | The client to build transactions and read with. Defaults to a gRPC client on the network's public RPC. It must be a gRPC client. | | `walrusWriteConfig` | `{ wasmUrl?, uploadRelay? }` | Options for the default Walrus write adapter. See [Walrus upload relay](#upload-relay). | | `walrusWrite` | `WalrusWriteAdapter & WalrusFlowCapable` | A prebuilt write adapter. It replaces the default one, so `walrusWriteConfig` is ignored. | | `walrusRead` | `WalrusReadAdapter` | A prebuilt read adapter. Defaults to the Walrus aggregator. | | `sealKeyServers` | `readonly KeyServerConfig[]` | Seal key servers for the default Seal adapter. Needed for encrypted files on Walrus Mainnet. See [Seal on Walrus Mainnet](#seal-on-walrus-mainnet). | | `seal` | `SealAdapter` | A prebuilt Seal adapter. It overrides `sealKeyServers`. | | `pubkeyCache` | `PubkeyCache` | Cache for the wallet's public key. Defaults to a `localStorage` cache in the browser. | > **WARNING: Point the wallet and the SDK at one RPC** > > The SDK builds and reads over gRPC, while dapp-kit executes over its own JSON-RPC client. Its last release is 1.1.17: Mysten has [deprecated](https://sdk.mystenlabs.com/sui/migrations/sui-2.0/dapp-kit) the package because it supports JSON-RPC only. Mysten's public fullnodes have retired JSON-RPC, so dapp-kit fails with `TypeError: Failed to fetch` before the wallet opens unless its `SuiClientProvider` points at an endpoint that still serves it. When the two clients sit on different nodes, the certify step can also fail with an object-version conflict. For reliable uploads, point `SuiClientProvider` and the `suiClient` option at one dedicated node. ## Components The styled components live in the `/ui` entry. Import the stylesheet once. ```tsx import { toSuiAddress } from "@arcadiasystems/morse-sdk"; import { MorseFileUploader } from "@arcadiasystems/morse-uploader/ui"; import "@arcadiasystems/morse-uploader/styles.css"; // Public upload console.log(result.shareLink)} /> // Encrypted upload. The connected wallet is always added as a recipient. const recipients = ["0x71a3...9f2b", "0x88c2...41de"].map((address) => toSuiAddress(address)); console.log(result.shareLink)} /> ``` | `MorseFileUploader` prop | Type | Default | Description | | --- | --- | --- | --- | | `encrypt` | `boolean` | `false` | Encrypt under Seal so only the recipients can decrypt. | | `recipients` | `readonly SuiAddress[]` | None | Wallets that can decrypt. The connected wallet is always added on-chain. | | `defaultEpochs` | `number` | `5` | Starting storage duration, in Walrus epochs. | | `minEpochs` | `number` | `1` | Shortest duration a user can pick. | | `maxEpochs` | `number` | `53` | Longest duration a user can pick. 53 epochs is the Walrus lease cap. | | `onUploaded` | `(result: CompletedFileUpload) => void` | None | Called when an upload completes, with the file ID, the Seal material and the share link. | | `className` | `string` | None | Extra class on the root element. | The uploader shows a USD storage-cost estimate before upload, a progress stepper during it, and a share link with a copy button after it. It renders failures with `uploadErrorMessage`, so a file too large for the relay's tip cap shows "File too large". See [File size limit](#file-size-limit). `MorseFileDownloader` opens one file from a share link. It reads the on-chain record, then downloads a public file or decrypts an encrypted one, with the original file name and type. Images get a preview. It renders failures with `downloadErrorMessage`, so a link that cannot open an encrypted file shows "Link is incomplete" or "Link does not match this file". See [Download errors](#download-errors). ```tsx import type { RecipientFileId } from "@arcadiasystems/morse-sdk"; import { parseShareLink } from "@arcadiasystems/morse-uploader"; import { MorseFileDownloader } from "@arcadiasystems/morse-uploader/ui"; export function SharedFile() { const link = parseShareLink(window.location.href); if (link === null) { return

This link is damaged or is not a Morse share link.

; } return ( ); } ``` | `MorseFileDownloader` prop | Type | Description | | --- | --- | --- | | `fileId` | `RecipientFileId` | The file's on-chain ID, from the share link. Required. | | `sealIdPrefix` | `Uint8Array \| null` | Seal material for an encrypted file. Leave it out, or pass `null`, for a public file. | | `sealNonce` | `Uint8Array \| null` | The other half of the Seal material. Pass it together with `sealIdPrefix`. | | `className` | `string` | Extra class on the root element. | To theme the components, set the `--mu-*` CSS variables on any ancestor, such as `--mu-accent-color`, `--mu-surface-color` and `--mu-fg-color`. Or restyle the `mu-*` classes directly. ## Hooks Build your own UI from the same hooks the components use. The file hooks read their handles from `MorseFilesProvider`, so call them inside it. | Hook | What it does | | --- | --- | | `useMorseFiles(options)` | Builds the handles from a connected wallet. Call it once, above the provider. | | `useMorseFilesContext()` | Returns the handles. Throws outside the provider. | | `useFileUpload()` | Uploads one file. `upload({ file, epochs, recipients?, encrypt?, deletable? })` resolves to `{ fileId, blobId, encrypted, sealIdPrefix, sealNonce, shareLink }`. `phase` tracks progress. A file too large for the relay's tip cap fails with `FileTooLargeError` before it is read. | | `useFileDownload(fileId, seal)` | Drives a download page. Loads the on-chain record, then `load()`, `save()` and `download()` read a public file or decrypt an encrypted one. Pass `seal` as `{ sealIdPrefix, sealNonce }` for an encrypted file, or `null`. A link that cannot open an encrypted file fails with `ShareLinkSealError`. | | `useFileDecrypt()` | Lower-level decrypt. `decrypt({ file, sealIdPrefix, sealNonce, ttlMin? })` resolves to the plaintext bytes. | | `useRecipientFile(fileId)` | Loads a file's on-chain record: name, content type, size, members and blob ID. | | `useStorageCostEstimate({ sizeBytes, epochs, encrypt?, usdPerGbPerMonth? })` | Estimates storage cost in USD from the Walrus price of $0.023 per GB per month and the network's nominal epoch length. | | `useDropzone(options)` | Headless drag-and-drop file picker with prop getters. Works without the provider. | | `useClipboard(resetMs?)` | Copies text and sets `copied` for `resetMs` (2000 ms by default). Works without the provider. | ### Upload from a hook ```tsx import { uploadErrorMessage, uploadPhaseLabel, useFileUpload, useMorseFilesContext, } from "@arcadiasystems/morse-uploader"; export function UploadButton({ file }: { file: File }) { const { network } = useMorseFilesContext(); const { upload, status, phase, data, error } = useFileUpload(); const message = error === null ? null : uploadErrorMessage(error, network); return (
{phase !== null &&

{uploadPhaseLabel(phase)}

} {data !== null &&

{data.shareLink}

} {message !== null &&

{message.title}: {message.description}

}
); } ``` An upload moves through the phases `encrypting`, `uploading`, `submitting` and `complete`. Public uploads skip `encrypting`. `uploadPhases(encrypted)` returns the ordered list for a progress stepper, and `uploadPhaseLabel(phase)` returns a readable label. Blobs are created deletable unless you pass `deletable: false`. `uploadErrorMessage(error, network)` returns the SDK's `{ title, description, cause }` for any failure. It titles a `FileTooLargeError` "File too large", with the file's size, the limit and the tip cap. It rewrites an insufficient-balance error as "Not enough WAL" or "Not enough SUI", with the amounts in whole coins and how to top up on that network. Everything else goes to the SDK's `formatUserMessage`, so the wording follows the `morse-sdk` version your app installs. ### Share links `buildShareLink({ fileId, network, sealIdPrefix, sealNonce })` builds a link of the form `/#/f/?net=&p=&n=` on the current origin. `useFileUpload` already returns one as `shareLink`. The `baseUrl` and `pattern` options change the origin and the path. Everything after `#` stays in the browser, so none of it reaches a server log. The Seal material in the link is not a secret: decryption is still gated by the file's recipient set. `parseShareLink(href)` reads a link back into `{ fileId, network, sealIdPrefix, sealNonce }`. The Seal fields are `null` for a public file. It returns `null` for a damaged link, including one that carries only one of `p` and `n`. A file ID exists on one network only, so check `network` before you look the file up. ### Download errors Before `load()` or `download()` reads any bytes or asks the wallet to sign, `useFileDownload` checks the share link against the file's on-chain record. When the record has a Seal prefix and the link cannot open the file, the hook sets `status` to `"error"` and `error` to a `ShareLinkSealError`. Its `reason` says what is wrong with the link: | `reason` | The link | `MorseFileDownloader` shows | | --- | --- | --- | | `"missing"` | Carries no Seal material, for example because the end of the link was cut off or the link was rebuilt from the file ID. | "Link is incomplete" | | `"mismatch"` | Carries a Seal prefix that is not the file's, so it is damaged or belongs to another file. | "Link does not match this file" | Both descriptions ask the reader to get the full link from the sender. A file whose record has no Seal prefix, such as one encrypted before the prefix was stored on chain, is not checked: as before, the hook decrypts it when the link carries Seal material and reads it as a public file when the link does not. The check runs only when the bytes are fetched, so a page that shows just the file's details makes no extra request. For your own UI, `downloadErrorMessage(error)` turns the hook's `error` into `{ title, description, cause }`, as `MorseFileDownloader` does. It titles a `ShareLinkSealError` by its `reason` and hands everything else to the SDK's `formatUserMessage`, so that wording follows the `morse-sdk` version your app installs. On 0.10, for example, a blob Walrus has no copy of is titled "Content unavailable" and reads "Walrus has no stored copy of this content." with its ID. ```tsx import type { RecipientFileId } from "@arcadiasystems/morse-sdk"; import { downloadErrorMessage, type SealMaterial, useFileDownload, } from "@arcadiasystems/morse-uploader"; export function DownloadButton({ fileId, seal, }: { fileId: RecipientFileId; seal: SealMaterial | null; }) { const { file, status, error, download } = useFileDownload(fileId, seal); const message = error === null ? null : downloadErrorMessage(error); return (
{message !== null &&

{message.title}: {message.description}

}
); } ``` ## Walrus upload relay A direct Walrus write sends slivers to every storage node at once, a burst that many networks cannot sustain. In a browser, write through the upload relay instead: it does that fan-out for you over one connection. The default write adapter uses it when you set `walrusWriteConfig.uploadRelay`, which the uploader hands unchanged to `@mysten/walrus`. Its type is exported as `UploadRelayOptions`: a `host` and an optional `sendTip`, typed as `@mysten/walrus`'s `UploadRelayConfig["sendTip"]`, so a value `@mysten/walrus` would not accept does not type-check. | Network | Relay host | | --- | --- | | Testnet | `https://upload-relay.testnet.walrus.space` | | Walrus Mainnet | `https://upload-relay.mainnet.walrus.space` | ```tsx const UPLOAD_RELAY_HOSTS = { testnet: "https://upload-relay.testnet.walrus.space", mainnet: "https://upload-relay.mainnet.walrus.space", } as const; const setup = useMorseFiles({ network, account, callbacks, walrusWriteConfig: { wasmUrl: walrusWasmUrl, uploadRelay: { host: UPLOAD_RELAY_HOSTS[network], sendTip: { max: 10_000_000 }, // the most you will pay per upload, in MIST }, }, }); ``` The relay charges a tip on every upload, paid in SUI on top of WAL storage and gas. Set `sendTip: { max }`, with `max` in MIST. `@mysten/walrus` then reads the relay's live tip from `/v1/tip-config` and pays it, up to `max`. - **Always set `sendTip`.** Without it, no tip is sent and the relay refuses the upload. - **Leave room in `max`.** On Walrus Mainnet the tip grows with the file's encoded size, so `max` also caps the file size. At 10,000,000 MIST, the cap the example app uses, the largest file is 42,327,820 bytes (40.37 MiB). Even a small file costs more than 1,000,000 MIST, so a cap sized for testnet rejects every Walrus Mainnet upload. Testnet's relay charges a flat 105 MIST, so there the cap sets no size limit. See [File size limit](#file-size-limit). - **The relay sees your bytes.** Encrypt the file if that matters. In a browser, the Walrus client also needs the URL of its wasm file. It comes from `@mysten/walrus-wasm`, a dependency of `@mysten/walrus`. The example app installs it directly and imports the URL with Vite's `?url` suffix. Without an explicit URL, Vite serves `index.html` in place of the wasm file and the Walrus client fails to start. To skip the default adapter, pass a prebuilt `walrusWrite` adapter instead. It must implement `WalrusFlowCapable`, which the two-popup upload needs. ### File size limit Before it reads a file, `useFileUpload` reads the relay's `/v1/tip-config`, works out the largest file `sendTip.max` allows, and refuses a larger one with a `FileTooLargeError`. Nothing has been read, encrypted, encoded or signed at that point. `MorseFileUploader` shows "File too large", with the file's size, the limit and the cap. Raise `sendTip.max` to allow larger files. An encrypted file can fit and still go over once Seal's envelope is added, and the envelope's size depends on the key servers. That file is refused after encryption and before Walrus encodes it, with `afterEncryption` set to `true`. | Class | Carries | Thrown when | | --- | --- | --- | | `FileTooLargeError` | `sizeBytes`, `afterEncryption`, `limit` | The file, or the file once encrypted, is larger than `limit.maxBytes`. `sizeBytes` is the size Walrus would have stored. | | `ConfigurationError` | | `sendTip.max` is below the relay's tip for even the smallest file, so the relay would refuse every upload. The message says to raise `sendTip.max`. | | `TransportError` | `operation` | The relay's tip config could not be read. `operation` is `"walrus.relay.tipConfig"`. | `FileTooLargeError` is exported by this package, and `ConfigurationError` and `TransportError` by `morse-sdk`. `limit` is an `UploadSizeLimit`: `maxBytes`, the largest file in bytes, and `maxTipMist`, the cap it comes from, in MIST as a `bigint`. The check needs to know the relay. `useMorseFiles` sets `uploadRelay` on the `MorseFilesHandles` from `walrusWriteConfig.uploadRelay`. With a prebuilt `walrusWrite` it stays unset, because the package cannot see that adapter's relay, so the check is skipped. `@mysten/walrus` then still throws `Tip amount (N) exceeds the maximum allowed tip (M)` before anything is signed, but only after encoding the whole file. Hand-built handles can set `uploadRelay` to get the check. Each upload through a relay with a `sendTip.max` costs one extra request, for the tip config. ## Encrypted files and Seal on Walrus Mainnet An encrypted upload encrypts the file under Seal before it leaves the browser, then creates the file and binds its Seal identity in one transaction. The connected wallet is always a recipient, so the owner can always decrypt. At decrypt time, Seal key servers check the file's recipient set on-chain, and a wallet outside it gets no key. Decrypting takes one wallet popup to create a Seal `SessionKey`, which is reused across files until it expires (10 minutes by default; set it with `ttlMin`). Before it creates that session, `useFileDecrypt` checks that the handles' `signer` belongs to their `address`, because `@mysten/seal` 1.5 no longer does. On a mismatch it rejects with Seal's `UserError`, without opening the wallet or caching a session. `useFileDownload` and `MorseFileDownloader` decrypt through it too. Handles built by `useMorseFiles` always match, so only hand-built handles can fail the check. A signer whose key is an alias of the address is not supported. Testnet pins an open set of key servers, so encryption works there with no setup. Walrus Mainnet pins none, because every Seal operator there is commercial. To encrypt on Walrus Mainnet, pass `sealKeyServers`. Start from the SDK's `MAINNET_SEAL_COMMITTEE` and add the `apiKeyName` and `apiKey` your operator issues: ```tsx import { MAINNET_SEAL_COMMITTEE } from "@arcadiasystems/morse-sdk"; const setup = useMorseFiles({ network: "mainnet", account, callbacks, walrusWriteConfig: { wasmUrl: walrusWasmUrl, uploadRelay: { host: "https://upload-relay.mainnet.walrus.space", sendTip: { max: 10_000_000 }, }, }, sealKeyServers: MAINNET_SEAL_COMMITTEE.map((server) => ({ ...server, apiKeyName: "X-API-Key", apiKey: sealApiKey, // issued by your Seal operator })), }); ``` For full control, pass a prebuilt `seal` adapter instead. It overrides `sealKeyServers`. Without either, setup still succeeds and `handles.seal` is `null`. Public upload and download keep working, and the encrypted paths reject with an error that names `sealKeyServers`. Read `useMorseFilesContext().seal` and hide the encrypt option when it is `null`. Note that `morse-sdk` verifies its Walrus paths end to end on Walrus Mainnet, but not Seal, because that needs commercial credentials. > **WARNING: Keep the Seal credential out of the browser bundle** > > Anything in a browser bundle is visible to whoever opens devtools. Do not ship the key in client code you do not control. If the credential is not meant to be public, proxy it through your own backend. The Seal material that decrypts a file (`sealIdPrefix` and `sealNonce`) is not stored on-chain. The share link carries it. If your app lists files in its own UI, store both values next to the file ID. ## Limitations - **Storage is a lease.** Walrus stores a file for at most 53 epochs ahead, then it expires. An epoch is about 1 day on testnet and about 2 weeks on Walrus Mainnet. - **No file listing yet.** A `RecipientFile` is a shared object, so it never shows up in a wallet's owned objects. Listing a wallet's files needs an event indexer, and the package ships no listing UI until one exists. - **Recipients are set at upload.** The components do not change recipients afterwards. The SDK's owner-only `addRecipient` and `removeRecipient` do. ## Next steps - [Recipient Files](https://docs.morsecms.xyz/recipient-files.html): The on-chain model behind each upload: the recipient set, Seal identities and decryption. - [SDK: Setup](https://docs.morsecms.xyz/sdk-client-setup.html): Wallet adapters, Walrus adapters and error handling in morse-sdk. - [morse-uploader on npm](https://www.npmjs.com/package/@arcadiasystems/morse-uploader): The package page, with versions and install details. --- # Contract Overview > The Morse smart contract is a Move package named `publication` deployed on Sui. It manages publications, collections, and entries on-chain, with content stored on Walrus. Interact with it directly via Sui Programmable Transaction Blocks (PTBs) using `@mysten/sui`. > **INFO: Testnet deployment** > > Use [SDK Setup → morseConfig()](https://docs.morsecms.xyz/sdk-client-setup.html#morse-config) to get the current package ID, original ID, and registry ID. These values update automatically when the contract is upgraded. ## Getting started Use `morseConfig()` from `@arcadiasystems/morse-sdk` to resolve the package and registry addresses, then construct PTBs with `tx.moveCall`. See the [Contract Reference](https://docs.morsecms.xyz/contracts-reference.html) for complete function signatures and struct definitions, and the [Contract Cookbooks](https://docs.morsecms.xyz/contracts-workflows.html) for step-by-step recipes. ## Package structure The package compiles three modules. Every public entry point lives in `publication::publication`; the other two modules define the data types and internal logic it builds on. | Module | Source file | Role | | --- | --- | --- | | `publication::publication` | `sources/publication.move` | Defines `Publication`, `OwnerCap`, `PublisherCap`, and `PublicationRegistry`. Top-level API for publication lifecycle, capability issuance, collection and entry operations, and Seal approval. | | `publication::collection` | `sources/collection.move` | Defines `Collection` and the storage-mode constant, manages the per-collection `Table`. | | `publication::entry` | `sources/entry.move` | Defines `Entry`, `EntryRevision`, and the `BlobRef` enum. Contains revision append and publish logic and access-policy validation. | ## Struct definitions ### Publication module ```move // Root container for a publication. Shared object - key only (no store). public struct Publication has key { id: UID, name: String, slug: String, collections: VecMap, revoked_publisher_caps: Table, } // Shared singleton. Created by the package initializer. Holds slug-to-ID mapping. public struct PublicationRegistry has key { id: UID, slugs: Table, } // Proves ownership. One per publication. Transferable. public struct OwnerCap has key { id: UID, publication_id: ID, } // Grants write access. Many per publication. Holder-bound. public struct PublisherCap has key { id: UID, publication_id: ID, holder: address, } ``` ### Collection module ```move // Stored value inside Publication.collections VecMap. public struct Collection has store { name: String, storage_mode: u8, // 0 = STORAGE_MODE_BLOB, 1 = STORAGE_MODE_QUILT. Immutable. next_entry_id: u64, // Monotonic counter; never decremented on delete. entries: Table, } ``` ### Entry module ```move // Stored value inside Collection.entries Table. public struct Entry has store, drop { name: String, revisions: vector, draft_head: Option, public_head: Option, } // Immutable revision metadata appended to Entry.revisions. public struct EntryRevision has store, drop { blob_ref: BlobRef, content_type: String, encrypted: bool, access_policy: u8, seal_id: Option>, author: address, } // Identifies the Walrus content for a revision. // Blob(ID): Sui object ID of a standalone Walrus Blob (STORAGE_MODE_BLOB). // QuiltPatch(vector): 37-byte QuiltPatchId - quilt_blob_id (32) || version (1) || start_index (2) || end_index (2). public enum BlobRef has copy, drop, store { Blob(ID), QuiltPatch(vector), } ``` ## What PTBs give you - **Full transaction control**: Compose multiple Move calls in a single PTB: create a publication, add a collection, and publish an entry atomically. - **No SDK abstraction layer**: Call the contract functions directly. Useful when the SDK does not expose a wrapper for your use case or when you need fine-grained control. - **Custom batching**: Delete 50 entries, 3 collections, and a publication in one transaction. The SDK processes these sequentially; PTBs batch them. - **BCS encoding**: Pass Option> and other Move types directly via @mysten/sui/bcs. Required for encrypted entries and quilt patches. ## Known limitations - **No encrypted publish path.** `publish_from_draft` and `publish_direct` hardcode `encrypted = false`. Encrypted content stays as drafts. See [Entries → Adding an entry](https://docs.morsecms.xyz/entries.html#adding-an-entry) for the full lifecycle. - **Only the Publisher access policy is enforced.** The `Subscription` policy constant is defined but the approval entry point is not yet implemented. - **All blobs must be deletable.** The contract calls `blob.is_deletable()` and aborts if a permanent blob is passed. - **Single entry point module.** All public functions live in `publication::publication`. The other modules are internal. ## Next steps - [Smart Contracts: Reference](https://docs.morsecms.xyz/contracts-reference.html): Complete function signatures, events, error codes, and constants. - [Smart Contracts: Cookbooks](https://docs.morsecms.xyz/contracts-workflows.html): Step-by-step recipes for creating publications, managing entries, and encrypting content via PTBs. --- # Contract Reference > Complete reference for the Morse Move package: function signatures, struct definitions, events, error codes, and constants for the `publication`, `collection`, and `entry` modules. ## Publication module All functions live in `publication::publication`. Functions that accept a `&PublisherCap` also validate that the cap is not revoked and that `cap.holder == ctx.sender()`. ### Publication lifecycle | Function | Description | | --- | --- | | `new_publication(registry: &mut PublicationRegistry, name: String, slug: String, ctx: &mut TxContext)` | Returns `(Publication, OwnerCap, PublisherCap)`. Create a publication and register its slug. Call `share_publication` and transfer caps in the same PTB. | | `share_publication(publication: Publication)` | Share the publication object. Call after `new_publication` in the same PTB. | | `delete_publication(registry: &mut PublicationRegistry, publication: Publication, owner_cap: OwnerCap)` | Requires `OwnerCap` (consumed). Delete a publication and release its slug. All collections must be empty first. | ### Registry queries | Function | Description | | --- | --- | | `contains_slug(registry: &PublicationRegistry, slug: String)` | Returns `bool`. Whether a slug is currently registered. | | `get_publication_id_by_slug(registry: &PublicationRegistry, slug: String)` | Returns `&ID`. Resolve a slug to its publication object ID. Aborts if not registered. | ### Capability management | Function | Description | | --- | --- | | `transfer_owner_cap(owner_cap: OwnerCap, recipient: address)` | `OwnerCap` is consumed. Transfer ownership to another address. | | `issue_publisher_cap(publication: &mut Publication, owner_cap: &OwnerCap, holder: address, ctx: &mut TxContext)` | Requires `OwnerCap`. Returns `PublisherCap`. Mint a new cap bound to `holder`. Emits `PublisherCapIssued`. | | `revoke_publisher_cap(publication: &mut Publication, owner_cap: &OwnerCap, cap_id: ID)` | Requires `OwnerCap`. Add a cap ID to the revocation denylist. Emits `PublisherCapRevoked`. | | `destroy_publisher_cap(publication: &mut Publication, cap: PublisherCap, ctx: &mut TxContext)` | Requires `PublisherCap` (holder only, consumed). Surrender write access. If revoked, also removes cap from the denylist. | | `transfer_publisher_cap(cap: PublisherCap, recipient: address)` | `PublisherCap` is consumed. Transfer to another address. Does not change `holder` binding. | ### Collection management | Function | Description | | --- | --- | | `create_collection(publication: &mut Publication, cap: &PublisherCap, name: String, storage_mode: u8, ctx: &mut TxContext)` | Requires `PublisherCap`. Add a named collection. `storage_mode`: `0` (blob) or `1` (quilt), immutable. Emits `CollectionAdded`. | | `delete_collection(publication: &mut Publication, cap: &PublisherCap, name: String, ctx: &mut TxContext)` | Requires `PublisherCap`. Remove a named collection. Entries must be empty. Emits `CollectionRemoved`. | ### Entry management | Function | Description | | --- | --- | | `add_entry_to_collection(publication: &mut Publication, cap: &PublisherCap, collection_name: String, name: String, blob: &Blob, quilt_patch_id: Option>, content_type: String, encrypted: bool, access_policy: u8, seal_id: Option>, ctx: &mut TxContext)` | Requires `PublisherCap`. Returns `u64` (entry ID). Create a new entry in a named collection. | | `delete_entry_from_collection(publication: &mut Publication, cap: &PublisherCap, collection_name: String, entry_id: u64, ctx: &mut TxContext)` | Requires `PublisherCap`. Remove an entry by ID. The ID slot is not reused. | | `append_collection_entry_draft_revision(publication: &mut Publication, cap: &PublisherCap, collection_name: String, entry_id: u64, blob: &Blob, quilt_patch_id: Option>, content_type: String, encrypted: bool, access_policy: u8, seal_id: Option>, ctx: &mut TxContext)` | Requires `PublisherCap`. Returns `u64` (revision index). Append a draft revision and advance `draft_head`. | | `publish_collection_entry_from_draft(publication: &mut Publication, cap: &PublisherCap, collection_name: String, entry_id: u64, draft_revision_id: u64, blob: &Blob, quilt_patch_id: Option>, content_type: String, ctx: &mut TxContext)` | Requires `PublisherCap`. Returns `u64` (revision index). Publish from a draft revision. Advances `public_head`. | | `publish_collection_entry_direct(publication: &mut Publication, cap: &PublisherCap, collection_name: String, entry_id: u64, blob: &Blob, quilt_patch_id: Option>, content_type: String, ctx: &mut TxContext)` | Requires `PublisherCap`. Returns `u64` (revision index). Publish directly without a draft. Advances `public_head`. | ## Seal function The Seal approval entry point is called by the Seal network during decryption, not by end users directly. It is declared as an `entry` function (not `public fun`). | Function | Description | | --- | --- | | `seal_approve_publisher(id: vector, publication: &Publication, cap: &PublisherCap, ctx: &TxContext)` | Requires `PublisherCap`. Called by Seal during decryption. Validates active cap and Seal identity namespace. Format: `[publication_id_bytes][0x01][nonce]`. | ## Collection module These functions are defined in `publication::collection`. All take a `&Collection` reference; the collection is always accessed through the parent `Publication` object. | Function | Description | | --- | --- | | `get_name(collection: &Collection)` | Returns `String`. The collection's display name. | | `get_storage_mode(collection: &Collection)` | Returns `u8`. Storage mode constant: `0` for blob, `1` for quilt. Immutable after creation. | | `entries_length(collection: &Collection)` | Returns `u64`. Current number of entries. Does not account for holes from deleted entries. | ## Entry module These functions are defined in `publication::entry`. Getter functions that operate on the latest revision use the last element of the `revisions` vector, not `public_head` or `draft_head`. | Function | Description | | --- | --- | | `get_name(entry: &Entry)` | Returns `String`. The entry's display name. | | `get_draft_head(entry: &Entry)` | Returns `Option`. Revision index of the current draft head. `None` if no draft exists. | | `get_public_head(entry: &Entry)` | Returns `Option`. Revision index of the current public head. `None` if not yet published. | | `get_blob_ref(entry: &Entry)` | Returns `BlobRef`. The blob reference from the latest revision. | | `get_content_type(entry: &Entry)` | Returns `String`. MIME content type from the latest revision. | | `get_encrypted(entry: &Entry)` | Returns `bool`. Whether the latest revision is encrypted. | | `get_access_policy(entry: &Entry)` | Returns `u8`. Access policy of the latest revision: `0` = public, `1` = publisher, `2` = subscription. | | `get_seal_id(entry: &Entry)` | Returns `Option>`. Seal identity from the latest revision. `None` for unencrypted revisions. | | `get_author(entry: &Entry)` | Returns `address`. Author of the latest revision (`ctx.sender()` at creation time). | | `get_revision(entry: &Entry, revision_id: u64)` | Returns `&EntryRevision`. A specific revision by zero-based index. Aborts with `ERevisionNotFound` if out of bounds. | | `access_policy_public()` | Returns `u8` (`0`). The `ACCESS_PUBLIC` constant. | | `access_policy_publisher()` | Returns `u8` (`1`). The `ACCESS_PUBLISHER` constant. | | `access_policy_subscription()` | Returns `u8` (`2`). The `ACCESS_SUBSCRIPTION` constant. Reserved, not yet implemented. | ## Constants | Constant | Value | Module | Meaning | | --- | --- | --- | --- | | `MAX_SLUG_LENGTH` | `64` | `publication` | Maximum slug length in bytes. | | `SEAL_POLICY_TAG_PUBLISHER` | `1` | `publication` | Policy tag byte for publisher-gated Seal identities. Placed after the publication ID bytes in the identity prefix. | | `STORAGE_MODE_BLOB` | `0` | `collection` | Each entry references a standalone Walrus Blob by its Sui object ID. Use for dynamic collections. | | `STORAGE_MODE_QUILT` | `1` | `collection` | Entries are patches within a shared Walrus Quilt blob, referenced by QuiltPatchId. Use for small, rarely-changing collections. | | `MAX_ENTRY_NAME_LENGTH` | `256` | `entry` | Maximum entry name length in bytes. | | `MAX_CONTENT_TYPE_LENGTH` | `255` | `entry` | Maximum `content_type` length in bytes. | | `QUILT_PATCH_ID_LENGTH` | `37` | `entry` | Required exact byte length of a QuiltPatchId: `quilt_blob_id` (32) \|\| `version` (1) \|\| `start_index` (2) \|\| `end_index` (2). | | `ACCESS_PUBLIC` | `0` | `entry` | Unencrypted public access policy. | | `ACCESS_PUBLISHER` | `1` | `entry` | Publisher-gated encrypted access policy. | | `ACCESS_SUBSCRIPTION` | `2` | `entry` | Subscription-gated access policy (reserved for future use). | ## Object abilities | Type | Abilities | Notes | | --- | --- | --- | | `Publication` | `key` | Shared object. No `store`: prevents wrapping outside this module. | | `PublicationRegistry` | `key` | Shared singleton. Created by the package `init()` function at publish time. | | `OwnerCap` | `key` | Owned object. Transferable. Exactly one exists per publication. | | `PublisherCap` | `key` | Owned object. Holder-bound. Multiple can exist per publication. No `store`: cannot be fabricated externally. | | `Collection` | `store` | Wrapped value inside `Publication.collections` VecMap. | | `Entry` | `store, drop` | Wrapped value inside `Collection.entries` Table. Dropped automatically on deletion. | | `EntryRevision` | `store, drop` | Element of the `Entry.revisions` vector. Immutable after creation. | | `BlobRef` | `copy, drop, store` | Enum: either `Blob(ID)` or `QuiltPatch(vector)`. Copyable because it is a reference, not the blob itself. | ## Next steps - [Smart Contracts: Overview](https://docs.morsecms.xyz/contracts-overview.html): Package structure, struct definitions, capabilities, and known limitations. - [Smart Contracts: Cookbooks](https://docs.morsecms.xyz/contracts-workflows.html): Step-by-step PTB recipes for publishing, encrypted entries, and cap management. --- # Contract Cookbooks > End-to-end recipes for interacting with Morse smart contracts directly via Sui Programmable Transaction Blocks (PTBs). Each recipe shows the raw `tx.moveCall` pattern using `@mysten/sui/transactions`. For the high-level SDK wrappers, see [SDK Cookbooks](https://docs.morsecms.xyz/sdk-workflows.html). For the full function reference, see [Contract Reference](https://docs.morsecms.xyz/contracts-reference.html). ## Prerequisites | Requirement | Details | Purpose | | --- | --- | --- | | Bun or Node | v1.2+ / ≥ 18 | JavaScript runtime | | SUI + WAL tokens | [SUI faucet](https://faucet.sui.io/) · [Swap SUI for WAL](https://stake-wal.wal.app/?network=testnet) | Gas fees (SUI) and Walrus storage fees (WAL) | | Bech32 private key | A `suiprivkey1...` string | Transaction signing. Export from your wallet's settings | | Morse SDK | `@arcadiasystems/morse-sdk` | Provides `morseConfig()` for package ID, registry ID, and RPC URL | Make sure the SDK and its peer dependencies are installed before continuing. See [SDK Setup](https://docs.morsecms.xyz/sdk-client-setup.html) for the install command. ## Setup All recipes below assume the following imports and configuration are in scope. For browser environments, replace `Ed25519Keypair` with a wallet signer. > **INFO: Why the SDK imports?** > > Contract calls are raw `tx.moveCall` with no SDK wrappers. The SDK is only used for configuration and adapters: `morseConfig()` resolves deployed addresses, `DefaultWalrusWriteAdapter` uploads blobs, and `DefaultSealAdapter` handles encryption. See [SDK Client Setup](https://docs.morsecms.xyz/sdk-client-setup.html) for details. ```typescript import { SuiGrpcClient } from "@mysten/sui/grpc"; import { Transaction } from "@mysten/sui/transactions"; import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519"; import { bcs } from "@mysten/sui/bcs"; import { morseConfig, DefaultWalrusWriteAdapter, DefaultSealAdapter } from "@arcadiasystems/morse-sdk"; // Resolve package ID, registry ID, and RPC endpoint for testnet const config = morseConfig({ network: "testnet" }); const PACKAGE_ID = config.packageId; const REGISTRY_ID = config.registryId; const client = new SuiGrpcClient({ network: "testnet", baseUrl: config.rpcUrl, }); const keypair = Ed25519Keypair.fromSecretKey(""); // Second keypair for delegated write-access examples const coAuthorKeypair = Ed25519Keypair.fromSecretKey(""); const walrusWriter = DefaultWalrusWriteAdapter.fromConfig( { network: "testnet", suiClient: client }, keypair, ); // Empty {} uses the default key-server list from config const seal = DefaultSealAdapter.fromMorseConfig(config, {}, client); ``` > **INFO: Run each recipe as a separate script** > > Each code block is a standalone script. Running multiple blocks in the same process can cause stale object version errors because the SDK caches object versions between transactions. To avoid this, run each block as its own script or combine operations into a single PTB. ## Creating a publication Creates a publication, shares it, and transfers the resulting capabilities to the caller in one atomic PTB. `new_publication` returns a tuple; destructure it as three separate transaction results. The slug is globally unique and immutable after creation. > **WARNING: OwnerCap and PublisherCap are `key`-only** > > Both caps lack the `store` ability and cannot be moved with `tx.transferObjects`. Use the contract's typed transfer wrappers `transfer_owner_cap` and `transfer_publisher_cap` instead. ```typescript const tx = new Transaction(); // new_publication returns (Publication, OwnerCap, PublisherCap). const created = tx.moveCall({ target: `${PACKAGE_ID}::publication::new_publication`, arguments: [ tx.object(REGISTRY_ID), tx.pure.string("My Blog"), tx.pure.string(`my-blog-${Date.now()}`), // slugs are globally unique on-chain ], }); const publicationArg = created[0]; const ownerCapArg = created[1]; const publisherCapArg = created[2]; // Share the publication so other transactions can access it. tx.moveCall({ target: `${PACKAGE_ID}::publication::share_publication`, arguments: [publicationArg], }); // Transfer caps using the contract's typed wrappers (key-only objects). tx.moveCall({ target: `${PACKAGE_ID}::publication::transfer_owner_cap`, arguments: [ownerCapArg, tx.pure.address(keypair.toSuiAddress())], }); tx.moveCall({ target: `${PACKAGE_ID}::publication::transfer_publisher_cap`, arguments: [publisherCapArg, tx.pure.address(keypair.toSuiAddress())], }); const result = await client.signAndExecuteTransaction({ transaction: tx, signer: keypair, include: { effects: true, objectTypes: true }, }); // Extract created object IDs from the transaction effects. const { effects, objectTypes } = result.Transaction; const createdObjects = effects.changedObjects .filter((c) => c.idOperation === "Created" && objectTypes?.[c.objectId]) .map((c) => ({ objectId: c.objectId, type: objectTypes[c.objectId] })); console.log("Created objects:", createdObjects); ``` See also: [SDK Cookbooks → Creating a publication](https://docs.morsecms.xyz/sdk-workflows.html#creating-a-publication) ## Adding a collection Collections are added to an existing publication. The storage mode is immutable after creation: `0` for blob mode and `1` for quilt mode. Use blob mode for growing content like posts; use quilt mode for small, rarely-changing content like config files. ```typescript const PUBLISHER_CAP_ID = "0xPUBLISHER_CAP_ID"; const tx = new Transaction(); // Blob mode (storage_mode = 0): each entry holds a standalone Walrus Blob object ID. tx.moveCall({ target: `${PACKAGE_ID}::publication::create_collection`, arguments: [ tx.object(PUBLICATION_ID), tx.object(PUBLISHER_CAP_ID), tx.pure.string("posts"), tx.pure.u8(0), // STORAGE_MODE_BLOB ], }); // Quilt mode (storage_mode = 1): entries are addressed by 37-byte QuiltPatchId. tx.moveCall({ target: `${PACKAGE_ID}::publication::create_collection`, arguments: [ tx.object(PUBLICATION_ID), tx.object(PUBLISHER_CAP_ID), tx.pure.string("config"), tx.pure.u8(1), // STORAGE_MODE_QUILT ], }); // A blob-mode collection for encrypted content (used in the Encrypted Content section below). tx.moveCall({ target: `${PACKAGE_ID}::publication::create_collection`, arguments: [ tx.object(PUBLICATION_ID), tx.object(PUBLISHER_CAP_ID), tx.pure.string("secret-blog"), tx.pure.u8(0), // STORAGE_MODE_BLOB ], }); await client.signAndExecuteTransaction({ transaction: tx, signer: keypair }); ``` See also: [SDK Cookbooks → Adding a collection](https://docs.morsecms.xyz/sdk-workflows.html#adding-a-collection) ## Adding an entry Upload content to Walrus first, then pass the resulting Blob object ID to `add_entry_to_collection`. The `blob` argument is a reference to a Walrus Blob Sui object (`&Blob`), not a blob ID hash. Only deletable blobs are accepted. For blob-mode collections, pass `None` for `quilt_patch_id` using BCS serialization as shown below. ```typescript const content = new TextEncoder().encode( JSON.stringify({ title: "My First Post", body: "Hello from Morse." }) ); const { blobObjectId: BLOB_OBJECT_ID } = await walrusWriter.uploadBlob(content, { epochs: 3, deletable: true, }); // BCS-encode Option>::None for quilt_patch_id and seal_id. const optionNone = bcs.option(bcs.vector(bcs.u8())).serialize(null).toBytes(); const tx = new Transaction(); const entryId = tx.moveCall({ target: `${PACKAGE_ID}::publication::add_entry_to_collection`, arguments: [ tx.object(PUBLICATION_ID), // &mut Publication tx.object(PUBLISHER_CAP_ID), // &PublisherCap tx.pure.string("posts"), // collection_name: String tx.pure.string("my-first-post"), // name: String tx.object(BLOB_OBJECT_ID), // blob: &Blob (must be deletable) tx.pure(optionNone), // quilt_patch_id: Option> = None tx.pure.string("text/plain"), // content_type: String tx.pure.bool(false), // encrypted: bool tx.pure.u8(0), // access_policy: u8 - 0 = PUBLIC tx.pure(optionNone), // seal_id: Option> = None ], }); // entryId is the returned u64 - the stable monotonic ID for this entry (0, 1, 2, ...). await client.signAndExecuteTransaction({ transaction: tx, signer: keypair, include: { events: true }, }); ``` See also: [SDK Cookbooks → Adding an entry](https://docs.morsecms.xyz/sdk-workflows.html#adding-an-entry) ## Publishing a new revision Revisions are appended to an existing entry. Two paths are available: publish directly in one call, or stage a draft first and promote it. ### Option A: publish direct Append a non-encrypted public revision in a single call, bypassing the draft stage. Advances `public_head` immediately. ```typescript const ENTRY_ID = 0; // u64 entry ID returned by add_entry_to_collection const updatedContent = new TextEncoder().encode( JSON.stringify({ title: "My First Post", body: "Updated content." }) ); const { blobObjectId: NEW_BLOB_ID } = await walrusWriter.uploadBlob(updatedContent, { epochs: 3, deletable: true, }); const optionNone = bcs.option(bcs.vector(bcs.u8())).serialize(null).toBytes(); const tx = new Transaction(); tx.moveCall({ target: `${PACKAGE_ID}::publication::publish_collection_entry_direct`, arguments: [ tx.object(PUBLICATION_ID), tx.object(PUBLISHER_CAP_ID), tx.pure.string("posts"), tx.pure.u64(ENTRY_ID), tx.object(NEW_BLOB_ID), // blob: &Blob tx.pure(optionNone), // quilt_patch_id: None tx.pure.string("text/plain"), ], }); await client.signAndExecuteTransaction({ transaction: tx, signer: keypair }); ``` ### Option B: draft then publish Stage a draft revision first (advances `draft_head` only), then promote it to a public revision. The published revision takes a fresh blob; `draft_revision_id` is used only to validate the draft exists on-chain. ```typescript const ENTRY_ID = 0; // u64 entry ID returned by add_entry_to_collection const draftContent = new TextEncoder().encode(JSON.stringify({ title: "Draft", body: "WIP" })); const { blobObjectId: DRAFT_BLOB_ID } = await walrusWriter.uploadBlob(draftContent, { epochs: 3, deletable: true, }); const finalContent = new TextEncoder().encode(JSON.stringify({ title: "Final", body: "Done" })); const { blobObjectId: FINAL_BLOB_ID } = await walrusWriter.uploadBlob(finalContent, { epochs: 3, deletable: true, }); const optionNone = bcs.option(bcs.vector(bcs.u8())).serialize(null).toBytes(); const tx = new Transaction(); // Step 1: append a draft revision (advances draft_head, does not touch public_head). const draftRevisionId = tx.moveCall({ target: `${PACKAGE_ID}::publication::append_collection_entry_draft_revision`, arguments: [ tx.object(PUBLICATION_ID), tx.object(PUBLISHER_CAP_ID), tx.pure.string("posts"), tx.pure.u64(ENTRY_ID), tx.object(DRAFT_BLOB_ID), // blob: &Blob tx.pure(optionNone), // quilt_patch_id: None tx.pure.string("text/plain"), tx.pure.bool(false), // encrypted tx.pure.u8(0), // access_policy: PUBLIC tx.pure(optionNone), // seal_id: None ], }); // Step 2: promote the draft to a public revision in the same PTB. tx.moveCall({ target: `${PACKAGE_ID}::publication::publish_collection_entry_from_draft`, arguments: [ tx.object(PUBLICATION_ID), tx.object(PUBLISHER_CAP_ID), tx.pure.string("posts"), tx.pure.u64(ENTRY_ID), draftRevisionId, // chain the return value from step 1 tx.object(FINAL_BLOB_ID), // blob: &Blob for the published revision tx.pure(optionNone), // quilt_patch_id: None tx.pure.string("text/plain"), ], }); await client.signAndExecuteTransaction({ transaction: tx, signer: keypair }); ``` See also: [SDK Cookbooks → Publishing a new revision](https://docs.morsecms.xyz/sdk-workflows.html#publishing-a-new-revision) ## Encrypted content Encrypted entries require `encrypted=true`, `access_policy=1` (Publisher), and a Seal identity in `seal_id`. Encrypt content with [Seal](https://docs.seal.mystenlabs.com/) first, then upload the ciphertext to Walrus. The Seal identity is a `vector`: `[publication_id_bytes (32)] || [0x01] || [nonce]`. Each entry should use a fresh nonce. ```typescript // Build a Seal identity: publication ID bytes (32) || policy tag 0x01 || nonce. function buildPublisherSealId(publicationId: string, nonce: Uint8Array): Uint8Array { // publicationId is a 0x-prefixed hex string; strip and decode to 32 bytes. const hex = publicationId.startsWith("0x") ? publicationId.slice(2) : publicationId; const idBytes = new Uint8Array(hex.match(/.{2}/g)!.map((b) => parseInt(b, 16))); const result = new Uint8Array(idBytes.length + 1 + nonce.length); result.set(idBytes, 0); result[idBytes.length] = 0x01; // SEAL_POLICY_TAG_PUBLISHER result.set(nonce, idBytes.length + 1); return result; } const nonce = crypto.getRandomValues(new Uint8Array(16)); const sealId = buildPublisherSealId(PUBLICATION_ID, nonce); // BCS-encode Option>::Some(sealId) and Option::None. const sealIdOption = bcs.option(bcs.vector(bcs.u8())).serialize([...sealId]).toBytes(); const optionNone = bcs.option(bcs.vector(bcs.u8())).serialize(null).toBytes(); // Encrypt plaintext with Seal using the sealId above. const plaintext = new TextEncoder().encode("secret content"); const { ciphertext } = await seal.encrypt(plaintext, { sealId }); const { blobObjectId: ENCRYPTED_BLOB_ID } = await walrusWriter.uploadBlob(ciphertext, { epochs: 3, deletable: true, }); const tx = new Transaction(); tx.moveCall({ target: `${PACKAGE_ID}::publication::add_entry_to_collection`, arguments: [ tx.object(PUBLICATION_ID), tx.object(PUBLISHER_CAP_ID), tx.pure.string("secret-blog"), tx.pure.string("private-post"), tx.object(ENCRYPTED_BLOB_ID), // blob: &Blob holding the ciphertext tx.pure(optionNone), // quilt_patch_id: None tx.pure.string("application/octet-stream"), tx.pure.bool(true), // encrypted: true tx.pure.u8(1), // access_policy: 1 = PUBLISHER tx.pure(sealIdOption), // seal_id: Some(sealId) ], }); await client.signAndExecuteTransaction({ transaction: tx, signer: keypair }); ``` > **WARNING: Encrypted entries are always created as drafts** > > The publish functions hardcode `encrypted=false` on the public path; they cannot promote encrypted content to a public head. Read encrypted entries via `seal.decrypt` using the `sealId` stored in the revision metadata. See also: [SDK Cookbooks → Encrypted content](https://docs.morsecms.xyz/sdk-workflows.html#encrypted-content) ## Delegating write access The owner issues `PublisherCap` objects to grant write access to co-authors. Revocation adds the cap ID to an on-chain denylist; all subsequent writes using that cap abort with `EPublisherCapRevoked`. See [Access Control](https://docs.morsecms.xyz/access-control.html) for the full capability model. ### Issuing a PublisherCap Only the `OwnerCap` holder can issue new caps. The cap's `holder` field is fixed at issuance; the bound address is what grants write rights, not object ownership. ```typescript const PUBLICATION_ID = "0xPUBLICATION_ID"; const OWNER_CAP_ID = "0xOWNER_CAP_ID"; const HOLDER_ADDRESS = "0xCO_AUTHOR_ADDRESS"; const tx = new Transaction(); // issue_publisher_cap returns the new PublisherCap. const newCap = tx.moveCall({ target: `${PACKAGE_ID}::publication::issue_publisher_cap`, arguments: [ tx.object(PUBLICATION_ID), tx.object(OWNER_CAP_ID), tx.pure.address(HOLDER_ADDRESS), ], }); // Transfer the cap to the co-author using the typed wrapper (key-only object). tx.moveCall({ target: `${PACKAGE_ID}::publication::transfer_publisher_cap`, arguments: [newCap, tx.pure.address(HOLDER_ADDRESS)], }); const result = await client.signAndExecuteTransaction({ transaction: tx, signer: keypair, include: { effects: true, objectTypes: true }, }); const { effects, objectTypes } = result.Transaction; const publisherCapId = effects.changedObjects .find((c) => c.idOperation === "Created" && objectTypes?.[c.objectId]?.includes("PublisherCap")) ?.objectId; console.log("Issued PublisherCap:", publisherCapId); ``` > **WARNING: Holder binding is immutable** > > The `holder` address is baked in at issuance. Transferring the cap object to a different address does not grant write rights to the new holder. To rotate authority, destroy the old cap and issue a fresh one bound to the new address. ### Revoking a PublisherCap Revocation adds the cap's ID to an on-chain denylist. The physical object is not consumed; only the cap's ID is passed via `tx.pure.id`. The holder retains the object but any subsequent write attempt aborts with `EPublisherCapRevoked`. ```typescript const PUBLISHER_CAP_ID_TO_REVOKE = "0xCAP_ID"; const tx = new Transaction(); tx.moveCall({ target: `${PACKAGE_ID}::publication::revoke_publisher_cap`, arguments: [ tx.object(PUBLICATION_ID), tx.object(OWNER_CAP_ID), tx.pure.id(PUBLISHER_CAP_ID_TO_REVOKE), // ID only, not the object itself ], }); await client.signAndExecuteTransaction({ transaction: tx, signer: keypair }); ``` ### Destroying a PublisherCap The cap holder voluntarily surrenders write access. Only the address bound as `holder` at issuance can call this. If the cap was previously revoked, destroying it also removes the entry from the denylist (storage cleanup). ```typescript const MY_PUBLISHER_CAP_ID = "0xPUBLISHER_CAP_ID"; const tx = new Transaction(); tx.moveCall({ target: `${PACKAGE_ID}::publication::destroy_publisher_cap`, arguments: [ tx.object(PUBLICATION_ID), tx.object(MY_PUBLISHER_CAP_ID), ], }); // Signed by the co-author (holder), not the publication owner. await client.signAndExecuteTransaction({ transaction: tx, signer: coAuthorKeypair }); ``` See also: [SDK Cookbooks → Delegating write access](https://docs.morsecms.xyz/sdk-workflows.html#delegating-write-access) ## Transferring ownership Transfer the `OwnerCap` to a new address. The recipient gains full governance rights: issuing and revoking publisher caps, deleting the publication, and transferring ownership again. Because `OwnerCap` is `key`-only (no `store`), you must use the typed `transfer_owner_cap` wrapper; `tx.transferObjects` will not work. ```typescript const NEW_OWNER = "0xNEW_OWNER_ADDRESS"; const tx = new Transaction(); tx.moveCall({ target: `${PACKAGE_ID}::publication::transfer_owner_cap`, arguments: [ tx.object(OWNER_CAP_ID), tx.pure.address(NEW_OWNER), ], }); await client.signAndExecuteTransaction({ transaction: tx, signer: keypair }); ``` > **WARNING: One-way for the sender** > > Once transferred, the original owner loses all governance rights immediately. The new owner could transfer back, but there is no built-in undo. See also: [SDK Cookbooks → Transferring ownership](https://docs.morsecms.xyz/sdk-workflows.html#transferring-ownership) ## Deleting and tearing down Deletion is strictly bottom-up. The Move layer enforces this: `delete_collection` calls `table::destroy_empty` on the entries table and aborts if any entries remain. `delete_publication` calls `collections.destroy_empty()` and aborts if any collections remain. Entry IDs are permanently retired on deletion and never reused. ### Delete entries ```typescript // Delete all entries from both collections in a single PTB. const tx = new Transaction(); tx.moveCall({ target: `${PACKAGE_ID}::publication::delete_entry_from_collection`, arguments: [ tx.object(PUBLICATION_ID), tx.object(PUBLISHER_CAP_ID), tx.pure.string("posts"), tx.pure.u64(0), ], }); tx.moveCall({ target: `${PACKAGE_ID}::publication::delete_entry_from_collection`, arguments: [ tx.object(PUBLICATION_ID), tx.object(PUBLISHER_CAP_ID), tx.pure.string("secret-blog"), tx.pure.u64(0), ], }); await client.signAndExecuteTransaction({ transaction: tx, signer: keypair }); ``` ### Delete a collection The collection must be empty (all entries deleted) before this call succeeds. ```typescript const tx = new Transaction(); tx.moveCall({ target: `${PACKAGE_ID}::publication::delete_collection`, arguments: [ tx.object(PUBLICATION_ID), tx.object(PUBLISHER_CAP_ID), tx.pure.string("posts"), ], }); tx.moveCall({ target: `${PACKAGE_ID}::publication::delete_collection`, arguments: [ tx.object(PUBLICATION_ID), tx.object(PUBLISHER_CAP_ID), tx.pure.string("config"), ], }); tx.moveCall({ target: `${PACKAGE_ID}::publication::delete_collection`, arguments: [ tx.object(PUBLICATION_ID), tx.object(PUBLISHER_CAP_ID), tx.pure.string("secret-blog"), ], }); await client.signAndExecuteTransaction({ transaction: tx, signer: keypair }); ``` ### Delete a publication Requires the `OwnerCap`. All collections must be deleted first. Both the publication object and the `OwnerCap` are consumed. The slug is released from the registry and becomes available for reuse. ```typescript const tx = new Transaction(); tx.moveCall({ target: `${PACKAGE_ID}::publication::delete_publication`, arguments: [ tx.object(REGISTRY_ID), tx.object(PUBLICATION_ID), tx.object(OWNER_CAP_ID), ], }); await client.signAndExecuteTransaction({ transaction: tx, signer: keypair }); ``` See also: [SDK Cookbooks → Deleting and tearing down](https://docs.morsecms.xyz/sdk-workflows.html#deleting-and-tearing-down) ## Troubleshooting Move aborts surface as `MoveAbort` errors in the RPC response with a module name and numeric code. The table below maps the most common codes to their cause. | Code | Error | Module | Cause | | --- | --- | --- | --- | | `6` | `ESlugAlreadyExists` | `publication` | A publication with that slug already exists in the registry. | | `2` | `EUnauthorized` | `publication` | The cap's `publication_id` does not match the publication object passed in the call. | | `4` | `EPublisherCapWrongHolder` | `publication` | The transaction sender does not match `cap.holder`. | | `5` | `EPublisherCapRevoked` | `publication` | The cap ID is on the publication's revoked denylist. Request a new `PublisherCap` from the owner. | | `0` | `ECollectionAlreadyExists` | `publication` | A collection with that name already exists in the publication. | | `0` | `EEntryNotFound` | `collection` | No entry exists for the given `entry_id` in that collection. | | `8` | `EBlobNotDeletable` | `entry` | The Walrus `Blob` object was not registered as deletable. Re-upload with `deletable: true`. | | `5` | `EInvalidAccessPolicy` | `entry` | `encrypted`, `access_policy`, and `seal_id` are inconsistent. Unencrypted requires `(false, 0, None)`; encrypted requires `(true, 1, Some(sealId))`. | | `12` | `ESealInvalidId` | `publication` | The Seal identity prefix does not match the publication's on-chain ID bytes. | | `13` | `ESealWrongPolicyTag` | `publication` | The byte at position 32 of the Seal identity is not `0x01` (the publisher policy tag). | ## Next steps - [CLI: Overview](https://docs.morsecms.xyz/cli-overview.html): Install the CLI and manage publications, collections, and entries from the terminal. - [SDK: Overview](https://docs.morsecms.xyz/sdk-overview.html): TypeScript client for building custom integrations and automating Morse operations. --- # Glossary > Terms used throughout the Morse protocol and this documentation. ## A **[Access Policy](https://docs.morsecms.xyz/entries.html#access-policy-and-encryption-rules)**: A numeric constant stored per revision that defines who can read the payload. Current values: `0` (public), `1` (publisher), `2` (subscription, reserved). Strictly coupled with the `encrypted` flag: public (`0`) requires `encrypted=false`; publisher (`1`) requires `encrypted=true`. ## B **[Blob Mode](https://docs.morsecms.xyz/collections.html#storage-modes)**: Storage mode `0` for collections. Each entry revision stores a `BlobRef::Blob(ID)` pointing to an independent Walrus `Blob` object by Sui object ID. Best for growing, frequently-changing collections. **[BlobRef](https://docs.morsecms.xyz/entries.html#the-blobref-enum)**: A Move enum stored per revision that abstracts the two Walrus payload reference formats. Variants: `Blob(ID)` for standalone blobs, `QuiltPatch(vector)` for 37-byte quilt patch identifiers. ## C **[Capability](https://docs.morsecms.xyz/access-control.html)**: A Move object that proves authorization to perform a specific action. Morse has two capability types: `OwnerCap` (admin authority) and `PublisherCap` (write authority). Capabilities cannot be fabricated; they can only be issued by the protocol. **CMS**: Content Management System. Software for creating, organizing, and publishing structured digital content. Morse implements a decentralized, headless CMS protocol where permission management lives on-chain rather than in a centralized backend. **[Collection](https://docs.morsecms.xyz/collections.html#what-is-a-collection)**: A named content lane inside a [publication](https://docs.morsecms.xyz/publications.html). Holds entries in a table keyed by monotonic `entry_id` values. Has an immutable `storage_mode` (blob or quilt). **[Content Type](https://docs.morsecms.xyz/entries.html#entry-structure)**: A MIME-format string stored per entry revision that describes the payload format (e.g. `application/json`, `text/markdown`, `image/png`). Max 255 characters. Lowercase is recommended for consistency but not enforced on-chain. ## D **dCMS**: Decentralized Content Management System, the category Morse belongs to. Publication identity, authorization, and state transitions are governed on-chain; payloads are stored off-chain in decentralized storage (Walrus). **[Denylist](https://docs.morsecms.xyz/access-control.html#the-denylist-model)**: The revocation model used for PublisherCaps. A `Table` on each publication stores IDs of revoked caps. Issuance writes nothing to the table; only revocations write. This optimizes for the common case where revocations never occur. **[Draft Head](https://docs.morsecms.xyz/entries.html#the-dual-head-model)**: An `Option` pointer in an Entry that tracks the index of the latest draft revision. May be `None` if no draft has been created. Updated by `append_collection_entry_draft_revision`. **[Dual-head Model](https://docs.morsecms.xyz/entries.html#the-dual-head-model)**: The entry versioning architecture where each entry maintains two independent revision head pointers: `draft_head` (latest work-in-progress) and `public_head` (currently published). Enables editorial iteration without disrupting stable reader delivery. ## E **[Entry](https://docs.morsecms.xyz/entries.html#entry-structure)**: A single content unit within a collection. Holds a name, an immutable revision vector, and two head pointers. Identified by a stable monotonic `entry_id` within its collection. **[Entry ID](https://docs.morsecms.xyz/entries.html#entry-ids)**: A `u64` assigned to an entry when added to a collection. Values start at `0` and increment monotonically. IDs are never reused after deletion (stable for external references). **[EntryRevision](https://docs.morsecms.xyz/entries.html#revisions)**: The Move struct that stores a single revision's data: `blob_ref`, `content_type`, `encrypted`, `access_policy`, `seal_id`, and `author`. Has `store, drop` abilities. Stored in an entry's `revisions` vector. **Epoch**: A Walrus storage duration unit, roughly **one day on testnet** (two weeks on mainnet). When uploading a blob, the `epochs` parameter controls how long the data is available on the Walrus network; expired blobs can be renewed before their deadline. Passed as `--epochs ` in the CLI or `epochs: number` in the SDK. **Events**: On-chain event structs emitted by the Morse contracts during state transitions. All have `copy, drop` abilities. Includes `PublicationCreated`, `PublicationDeleted`, `SlugRegistered`, `SlugReleased`, `PublisherCapIssued`, `PublisherCapRevoked`, `CollectionAdded`, and `CollectionRemoved`. See the Events tables on [Publications](https://docs.morsecms.xyz/publications.html#events), [Collections](https://docs.morsecms.xyz/collections.html#events), and [Entries](https://docs.morsecms.xyz/entries.html#events). ## G **gRPC**: A high-performance RPC framework used by `SuiGrpcClient` from `@mysten/sui/grpc`. The Morse SDK requires gRPC transport for all Sui interactions. Works in modern browsers without a proxy. See [SDK Overview → Known limitations](https://docs.morsecms.xyz/sdk-overview.html#known-limitations). ## H **Headless CMS**: A CMS architecture that separates content management from presentation. The CMS manages content and metadata; client applications decide how to render it. Content is delivered through APIs. Morse follows this model while decentralizing identity, authorization, and state transitions. **[Holder](https://docs.morsecms.xyz/access-control.html#publishercap)**: The `address` field on a PublisherCap that binds the capability to a specific wallet. Write operations check that `tx.sender() == cap.holder`. A cap cannot be shared between addresses. ## I **Indexer**: The off-chain service that listens to on-chain events and builds a read-optimized index of publications, collections, and entries. Currently planned. See [How Morse Works → Off-chain services](https://docs.morsecms.xyz/core-concepts.html#off-chain-services). ## M **[Monotonic ID](https://docs.morsecms.xyz/entries.html#entry-ids)**: A collection-scoped, auto-incrementing integer assigned to each entry. IDs are never reused after deletion. See [Entries → Entry IDs](https://docs.morsecms.xyz/entries.html#entry-ids). **Move**: The programming language used for smart contracts on the Sui blockchain. Morse's on-chain logic is written in Move. ## O **[OwnerCap](https://docs.morsecms.xyz/access-control.html#ownercap)**: The admin capability for a publication. Exactly one exists per publication. Grants authority to issue and revoke publisher caps, delete the publication, and transfer ownership. Has `key` ability only. Transferable via `transfer_owner_cap`. ## P **[Publication](https://docs.morsecms.xyz/publications.html#what-is-a-publication)**: The top-level Morse content domain. A shared Sui object that bundles a name, an immutable slug, named collections, and a revocation denylist. Created via `new_publication`. **[PublicationRegistry](https://docs.morsecms.xyz/publications.html#publication-registry)**: A shared singleton Sui object created at package deploy time. Maintains a `Table` that maps slugs to publication IDs, enforcing global slug uniqueness. Required argument for `new_publication` and `delete_publication`. **[Public Head](https://docs.morsecms.xyz/entries.html#the-dual-head-model)**: An `Option` pointer in an Entry that tracks the index of the currently published revision. What reader-facing clients should read. Updated by `publish_collection_entry_from_draft` and `publish_collection_entry_direct`. **[PublisherCap](https://docs.morsecms.xyz/access-control.html#publishercap)**: The write capability for a publication. Many can exist per publication. Grants authority to create/delete collections and entries, append drafts, and publish content. Bound to a `holder` address. Issued by the owner; revocable by the owner. **PTB**: Programmable Transaction Block, Sui's composable transaction format. Multiple operations can be combined into a single atomic transaction. The Morse protocol uses pure creation functions that return objects for callers to compose in PTBs. **[Policy Tag](https://docs.morsecms.xyz/access-control.html#seal-identity-format)**: A single byte in a Seal identity that identifies which access policy the identity targets. Currently only `0x01` (publisher) is defined. Allows future policy types to coexist within the same publication namespace. ## Q **[Quilt Mode](https://docs.morsecms.xyz/collections.html#storage-modes)**: Storage mode `1` for collections. Each entry revision stores a 37-byte `QuiltPatchId` pointing to a patch within a shared Walrus Quilt blob. Best for stable, grouped content sets. **[QuiltPatchId](https://docs.morsecms.xyz/collections.html#storage-modes)**: A 37-byte identifier used in quilt mode collections. Layout: `quilt_blob_id (32 bytes) || version (1 byte) || start_index (2 bytes) || end_index (2 bytes)`. Stored as a `BlobRef::QuiltPatch(vector)`. ## R **[Revision](https://docs.morsecms.xyz/entries.html#revisions)**: An immutable version of an entry at a point in time. Stored in the entry's `revisions: vector`. Each revision records a `BlobRef`, content type, encryption state, access policy, optional Seal identity, and author address. Revisions are never mutated; edits append new ones. **[Revision ID](https://docs.morsecms.xyz/entries.html#revisions)**: The index of a revision within an entry's `revisions` vector. Starts at `0`. Returned by all revision-appending functions. Used to reference a specific revision in `publish_collection_entry_from_draft`. **[Revocation](https://docs.morsecms.xyz/access-control.html#revoking-publisher-caps)**: The act of adding a `PublisherCap`'s object ID to a publication's denylist via `revoke_publisher_cap`. Immediate effect: the next write attempt using the revoked cap will abort. Historical content is unaffected. ## S **[Seal](https://docs.morsecms.xyz/access-control.html#seal-integration)**: Mysten Labs' on-chain access control layer for Walrus blob decryption. Morse integrates via the `seal_approve_publisher` entry function, which validates that an active publisher cap holder is authorized within a specific publication namespace. **[Seal Identity](https://docs.morsecms.xyz/access-control.html#seal-identity-format)**: A byte vector (`vector`) that Seal presents to the approval function during decryption. In Morse, the format is: `[publication_id (32 bytes)][policy_tag (1 byte)][nonce (variable)]`. Scopes decryption approval to a specific publication and policy. **[Shared Object](https://docs.morsecms.xyz/contracts-reference.html#object-abilities)**: A Sui object accessible to any transaction, not owned by a single address. In Morse, `Publication` and `PublicationRegistry` are shared objects. Shared objects enable multi-writer collaboration but require consensus on ordering. **[Slug](https://docs.morsecms.xyz/publications.html#the-slug-system)**: A short, unique identifier for a publication. Immutable after creation. Constraints: lowercase `a-z`, digits `0-9`, hyphens; max 64 characters; no leading/trailing hyphens. Enforced globally by the `PublicationRegistry`. **[Storage Mode](https://docs.morsecms.xyz/collections.html#storage-modes)**: An immutable property of a collection that determines how entry revision blob references are stored. `0` = blob mode (standalone Walrus blobs), `1` = quilt mode (QuiltPatchId references). **Sui**: The Layer 1 blockchain on which Morse contracts are deployed. Sui uses the Move programming language and features an object-centric data model, parallel execution, and programmable transaction blocks. ## T **Table**: A Sui framework type (`sui::table::Table`) used by Morse for dynamic key-value storage within on-chain objects. For example, collections store entries in a `Table`. ## W **WAL**: The utility token of the Walrus decentralized storage network. WAL is used to pay for blob storage and is separate from SUI (the gas token for Sui transactions). **Walrus**: MystenLabs' decentralized blob storage network integrated with Sui. Morse stores all content payloads in Walrus; the contracts hold references (`BlobRef`), not the raw bytes. Walrus is format-agnostic; payload interpretation is the client's responsibility. **[Walrus Blob](https://docs.morsecms.xyz/entries.html#walrus-blob-requirements)**: A Sui object (`walrus::blob::Blob`) representing a payload stored in the Walrus network. Production Morse write paths require a reference to this object (not just the blob ID) so the contract can verify the blob is deletable. --- # Use Morse with AI > Morse publishes its documentation in machine-readable formats and ships agent-operable tooling, so AI coding agents can learn the protocol from current context, and then run the entire backend themselves. ## Machine-readable docs Every page on this site is available in three agent-friendly forms. No scraping, no HTML parsing, just Markdown. | Format | URL | Use it for | | --- | --- | --- | | **llms.txt** | [`/llms.txt`](https://docs.morsecms.xyz/llms.txt) | Compact index of all pages with one-line descriptions. Point an agent here first so it can pick which pages to read. | | **llms-full.txt** | [`/llms-full.txt`](https://docs.morsecms.xyz/llms-full.txt) | The complete documentation concatenated into a single Markdown file. One fetch gives an agent everything. | | **Markdown twins** | `/.md` | Every page has a Markdown version at the same URL with `.md` instead of `.html` (e.g. [`/entries.md`](https://docs.morsecms.xyz/entries.md)). | ## Prime a coding agent Paste one line into your agent to load the full Morse context: ```prompt Read https://docs.morsecms.xyz/llms-full.txt, the full documentation for Morse, the decentralized headless CMS protocol on Sui + Walrus. Use it to help me build with the Morse CLI, TypeScript SDK, or Move contracts. ``` Or fetch context on demand from a terminal-capable agent: ```bash # Index of all pages curl -s https://docs.morsecms.xyz/llms.txt # One specific page as Markdown curl -s https://docs.morsecms.xyz/quick-start-cli.md # Everything at once curl -s https://docs.morsecms.xyz/llms-full.txt ``` > **TIP: Working in a Morse project?** > > Add the prime-your-agent prompt to your project's `CLAUDE.md`, `AGENTS.md`, or Cursor rules so every session starts with accurate Morse context instead of guesses. ## Copy any page for AI Each docs page has a **Copy page** button (top right of the content area) with four actions: - **Copy page as Markdown**: puts the page's Markdown twin on your clipboard, ready to paste into any assistant. - **View as Markdown**: opens the raw `.md` version in a new tab. - **Open in ChatGPT / Open in Claude**: opens the assistant with a prompt that references this page and the full docs. There is also **Ask AI** in the header (or press `⌘I`): type a question and it opens Claude or ChatGPT pre-loaded with the entire Morse documentation as context. ## Agent-first by design Morse is not just documented for agents; it is *operable* by them. There is no dashboard signup, no API-key console, no human-only provisioning step anywhere in the protocol. Everything is a CLI command, an SDK call, or a transaction: - **Provision from the terminal**: with `morse publication create`, `morse collection create` and `morse entry add`, a coding agent can go from nothing to published content without leaving the shell. See the [CLI quick start](https://docs.morsecms.xyz/quick-start-cli.html). - **Script it with the SDK**: `@arcadiasystems/morse-sdk` exposes the same operations as typed TypeScript with structured errors, ideal for agent-authored automation. See the [SDK quick start](https://docs.morsecms.xyz/quick-start-sdk.html). - **Verify on-chain**: every mutation returns a transaction digest an agent can check. State is queryable from the contracts directly; no opaque backend to trust. See [Smart Contracts](https://docs.morsecms.xyz/contracts-overview.html). - **Deterministic auth**: capability objects (`OwnerCap`, `PublisherCap`) make permissions explicit and inspectable, so an agent always knows what it is allowed to do. See [Access Control](https://docs.morsecms.xyz/access-control.html). ```bash # A complete agent-driven publishing flow npm install -g @arcadiasystems/morse-cli export MORSE_PRIVATE_KEY=suiprivkey1... # a funded testnet key for the agent export MORSE_NETWORK=testnet morse publication create --name "Agent Blog" --slug "agent-blog-$(date +%s)" # canonical on-chain identity morse collection create posts # content container echo '{"title":"Hello","body":"Sent by an agent."}' > post.json morse entry add hello --file ./post.json # payload to Walrus, proof on Sui morse entry get 0 # read the record back ``` ## Feedback loop If these docs are stale, missing, or confusing (for you or your agent), use the **Was this helpful?** control at the bottom of any page, or open an issue directly: - [File docs feedback](https://github.com/arcadiasystems/morse-dcms/issues/new?labels=docs-feedback): include the page URL and what was wrong. - [Suggest changes](https://github.com/arcadiasystems/morse-dcms): every page footer links to its source for direct edits.