@arcadiasystems/morse-cli 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,73 @@ All notable changes to `@arcadiasystems/morse-cli` are documented here. The
4
4
  format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this
5
5
  project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.4.0] - 2026-06-04
8
+
9
+ Ports the file commands to the SDK 0.4.0 `RecipientFile` primitive, which
10
+ replaces the per-wallet allowlist plus encrypted-file pair with a single object
11
+ that carries its recipient list inline. This is a breaking change to the `morse
12
+ file` surface and removes `morse allowlist` entirely.
13
+
14
+ ### Added
15
+
16
+ - `morse file recipient add|remove|list <file> [address]`: manage a file's
17
+ recipient list directly on the file object (replaces `morse allowlist`).
18
+ - Recipients on uploads and registration via repeatable `-r, --recipient <addr>`;
19
+ the sender is always included.
20
+ - Encrypted uploads emit a **share string** (`mf1.<fileId>.<prefix>.<nonce>`,
21
+ see `format/share.ts`) bundling everything a recipient needs to decrypt, plus
22
+ the raw `sealIdPrefix` and `sealNonce` (hex) in `--json`.
23
+ - `morse file download` accepts `--share <string>`, or `--prefix <hex> --nonce
24
+ <hex>`, to decrypt; the file id becomes an optional positional (the share
25
+ string carries it).
26
+ - `morse file register --encrypted --seal-prefix <hex>` / `--public`: register
27
+ on-chain metadata for an existing Walrus blob as a `RecipientFile`.
28
+
29
+ ### Changed
30
+
31
+ - Depends on `@arcadiasystems/morse-sdk` `^0.4.0`.
32
+ - `morse file list` reconciles `RecipientFile` events; `--accessible` now lists
33
+ files you can decrypt as a recipient (recipient-list membership), not allowlist
34
+ membership. The human listing shows a `recipients` count column.
35
+ - `morse file get` shows the recipient list instead of an allowlist reference.
36
+
37
+ ### Removed
38
+
39
+ - `morse allowlist` and all its subcommands. Recipient access now lives on the
40
+ file via `morse file recipient`.
41
+ - `file upload -a <allowlist>` and `file download --seal-id <hex>`. Use
42
+ `--encrypt` / `--recipient` on upload and `--share` (or `--prefix`/`--nonce`)
43
+ on download.
44
+
45
+ ## [0.3.0] - 2026-06-04
46
+
47
+ Event-based file listing, wrapping the SDK 0.3.0 reconcile helpers. Purely
48
+ additive; all existing commands are unchanged.
49
+
50
+ ### Added
51
+
52
+ - `morse file list`: list files owned by an address (default: the active
53
+ account), or, with `--accessible`, files decryptable via allowlist membership.
54
+ `--address <addr>` queries another address, `--hydrate` fetches the full record
55
+ per file (adds `blobId`; one read each), `--limit <n>` caps results, `--json`
56
+ emits the summary array.
57
+ - Event fetching via `cli/events.ts`: a paginator over `suix_queryEvents` feeding
58
+ the SDK's pure `reconcileFilesOwnedBy` / `reconcileFilesAccessibleBy` helpers.
59
+ `--indexer-url <url>` overrides the event source (any endpoint that speaks
60
+ `suix_queryEvents`).
61
+
62
+ ### Changed
63
+
64
+ - Depends on `@arcadiasystems/morse-sdk` `^0.3.0`.
65
+
66
+ ### Notes
67
+
68
+ - Listing reads `suix_queryEvents`, a deprecated Sui JSON-RPC endpoint Mysten is
69
+ sunsetting; on the public RPC it may degrade over time. Use `--indexer-url` to
70
+ point at your own indexer. Results are best-effort and eventually consistent.
71
+ - Summaries omit `blobId`/`blobObjectId` (not in the `FileCreated` event); use
72
+ `--hydrate` to fetch them.
73
+
7
74
  ## [0.2.0] - 2026-06-04
8
75
 
9
76
  Wraps the allowlist + encrypted-file surface from `@arcadiasystems/morse-sdk`
package/README.md CHANGED
@@ -6,7 +6,7 @@ content entries from your terminal, signing with a locally encrypted key.
6
6
  Content is stored on [Walrus](https://walrus.xyz); private entries are encrypted
7
7
  with [Seal](https://github.com/MystenLabs/seal).
8
8
 
9
- > Status: v0.2.0, targeting Sui testnet. The command surface is stable; mainnet
9
+ > Status: v0.3.0, targeting Sui testnet. The command surface is stable; mainnet
10
10
  > support lands when the contracts are frozen.
11
11
 
12
12
  ## Requirements
@@ -228,46 +228,52 @@ OwnerCap and PublisherCap IDs are auto-resolved from the active account when the
228
228
  `--owner-cap` / `--publisher-cap` override is omitted. Destructive operations
229
229
  (`delete`, `revoke`, `destroy`, `transfer`) confirm interactively unless `--yes`.
230
230
 
231
- ### allowlist
232
-
233
- Per-wallet allowlists gate who can decrypt encrypted files. The admin Cap is
234
- auto-resolved from the active account when `--cap` is omitted.
235
-
236
- | Command | Purpose |
237
- | --- | --- |
238
- | `allowlist create --name <name>` | Create an allowlist; transfers its admin Cap to you. |
239
- | `allowlist add-member <addr> -a <id> [--cap <id>]` | Add a wallet that may decrypt. |
240
- | `allowlist remove-member <addr> -a <id> [--cap <id>]` | Remove a wallet. |
241
- | `allowlist transfer-cap <recipient> -a <id> [--cap <id>] [-y]` | Hand off admin rights. |
242
- | `allowlist delete -a <id> [--cap <id>] [-y]` | Delete an allowlist (dependent files become undecryptable). |
243
- | `allowlist get <id>` | Show an allowlist's name and members. |
244
- | `allowlist list-caps [address]` | List allowlist admin Caps held by an address. |
245
-
246
231
  ### file
247
232
 
233
+ A `RecipientFile` is a single object that carries its own recipient list: the
234
+ addresses allowed to decrypt it live inline on the file, managed by its owner.
235
+ There is no separate allowlist object.
236
+
248
237
  | Command | Purpose |
249
238
  | --- | --- |
250
- | `file upload <path> --name <n> [-a <id>] [--public] [--content-type <m>] [--epochs <n>]` | Upload to Walrus and register; `-a` encrypts, `--public` is world-readable. |
251
- | `file register --blob-id <id> --name <n> --content-type <m> --size <bytes> [-a <id>] [--public] [--blob-object-id <id>]` | Register metadata for a blob already on Walrus. |
252
- | `file download <file> [--out <path>] [--seal-id <hex>] [--via-aggregator]` | Download content; decrypts in place when encrypted. |
253
- | `file get <file>` | Fetch a file's on-chain metadata. |
239
+ | `file upload <path> --name <n> (--public \| --encrypt \| -r <addr>...) [--content-type <m>] [--epochs <n>]` | Upload to Walrus and register. `--public` is world-readable; `--encrypt` (or supplying `-r`) encrypts, and the sender is always a recipient. One of the three is required. |
240
+ | `file register --blob-id <id> --name <n> --content-type <m> --size <bytes> (--public \| --encrypted --seal-prefix <hex>) [-r <addr>...] [--blob-object-id <id>]` | Register metadata for a blob already on Walrus. |
241
+ | `file download [file] [--out <path>] [--share <s>] [--prefix <hex> --nonce <hex>] [--via-aggregator]` | Download content; decrypts in place with a share string (or prefix + nonce). |
242
+ | `file list [--address <addr>] [--accessible] [--hydrate] [--limit <n>] [--indexer-url <url>]` | List files owned by (or, with `--accessible`, decryptable by) an address. |
243
+ | `file get <file>` | Fetch a file's on-chain metadata and recipient list. |
254
244
  | `file update <file> --name <n> --content-type <m>` | Update name and MIME (owner only). |
255
- | `file transfer-ownership <file> <newOwner> [-y]` | Transfer the metadata right (not decrypt access). |
245
+ | `file transfer-ownership <file> <newOwner> [-y]` | Transfer ownership (not recipient access, which the list governs). |
256
246
  | `file delete <file> [-y]` | Delete the metadata record (the Walrus blob expires on its own lease). |
257
-
258
- Encrypting a file (`file upload -a <allowlist>`) prints a **seal id**. It is not
259
- recoverable from the ciphertext, so save it: `file download` needs it (via
260
- `--seal-id`) plus allowlist membership to decrypt. Listing "files I can decrypt
261
- as a member" is not supported on-chain (encrypted files are shared objects with
262
- no owner index); track file ids yourself or index the contract events.
247
+ | `file recipient add <file> <address>` | Allow an address to decrypt the file. |
248
+ | `file recipient remove <file> <address>` | Revoke an address's access. |
249
+ | `file recipient list <file>` | List the file's recipients. |
250
+
251
+ An encrypted `file upload` prints a **share string** (`mf1.<fileId>.<prefix>.<nonce>`)
252
+ that bundles the file id and the Seal prefix and nonce. The prefix and nonce are
253
+ not recoverable from the ciphertext, so save the share string: `file download
254
+ --share <s>` needs it (plus being on the recipient list) to decrypt. `--json`
255
+ also returns the raw `sealIdPrefix` and `sealNonce` as hex, usable as `--prefix`
256
+ / `--nonce` if you prefer to pass them separately.
257
+
258
+ `file list` reconstructs the file set from contract events. `RecipientFile`
259
+ objects have no on-chain owner index for the shared case, so listing is
260
+ event-derived, not a direct query. By default the command reads events via
261
+ `suix_queryEvents` on the configured Sui RPC. That endpoint is **deprecated**
262
+ (Mysten is sunsetting it), so listing may degrade or stop working on the public
263
+ RPC over time; point `--indexer-url <url>` at any source that speaks
264
+ `suix_queryEvents` (a self-hosted indexer, a third-party endpoint) to stay in
265
+ control. Results are best-effort and eventually consistent (subject to indexer
266
+ lag and retention). Summary rows omit `blobId`/`blobObjectId`; add `--hydrate`
267
+ to fetch the full record per file (one read each) when you need them.
263
268
 
264
269
  ## Output and scripting
265
270
 
266
271
  - Human-readable output goes to stdout; progress, warnings, and prompts go to
267
272
  stderr. Redirecting stdout yields only the result.
268
273
  - `--json` emits a single JSON document on stdout (nothing else). `bigint` values
269
- (e.g. `gasUsedMist`) are encoded as decimal strings and byte arrays (`sealId`,
270
- quilt patch ids) as `0x`-hex.
274
+ (e.g. `gasUsedMist`) are encoded as decimal strings and byte arrays (quilt patch
275
+ ids) as `0x`-hex. Encrypted uploads also return `sealIdPrefix` / `sealNonce` as
276
+ hex and a `share` string.
271
277
  - Color is emitted only to a TTY and honors `NO_COLOR` and `FORCE_COLOR`.
272
278
 
273
279
  ## Exit codes
@@ -290,7 +296,7 @@ no owner index); track file ids yourself or index the contract events.
290
296
  - [`encrypt-decrypt.sh`](./examples/encrypt-decrypt.sh): encrypt with Seal and decrypt back.
291
297
  - [`delegation.sh`](./examples/delegation.sh): issue a PublisherCap to a delegate, then revoke it.
292
298
  - [`ci-noninteractive.sh`](./examples/ci-noninteractive.sh): env-var auth, `--yes`, and `--json` parsing.
293
- - [`files.sh`](./examples/files.sh): allowlist + encrypted file round-trip (create allowlist, add a member, upload, download/decrypt, plus a public file).
299
+ - [`files.sh`](./examples/files.sh): RecipientFile round-trip (upload an encrypted file, download/decrypt via its share string, manage recipients, plus a public file).
294
300
 
295
301
  ## Limitations
296
302
 
package/dist/index.js CHANGED
@@ -148,7 +148,7 @@ import { Command } from "commander";
148
148
  // package.json
149
149
  var package_default = {
150
150
  name: "@arcadiasystems/morse-cli",
151
- version: "0.2.0",
151
+ version: "0.4.0",
152
152
  description: "Command-line interface for the Morse decentralized CMS on Sui.",
153
153
  license: "MIT",
154
154
  type: "module",
@@ -203,7 +203,7 @@ var package_default = {
203
203
  prepublishOnly: "bun run check && bun run build"
204
204
  },
205
205
  dependencies: {
206
- "@arcadiasystems/morse-sdk": "^0.2.0",
206
+ "@arcadiasystems/morse-sdk": "^0.4.0",
207
207
  "@mysten/seal": "1.1.3",
208
208
  "@mysten/sui": "2.16.2",
209
209
  "@mysten/walrus": "1.1.6",
@@ -889,29 +889,30 @@ async function resolveActiveAccount(gopts) {
889
889
  return accountAddress(resolveSettings(gopts, await loadConfig()).account);
890
890
  }
891
891
 
892
- // src/commands/allowlist.ts
892
+ // src/commands/cap.ts
893
893
  import {
894
- addMember,
895
- createAllowlist,
896
- deleteAllowlist,
897
- removeMember,
898
- toAllowlistId,
894
+ destroyPublisherCap,
895
+ issuePublisherCap,
896
+ revokePublisherCap,
897
+ toPublisherCapId as toPublisherCapId2,
899
898
  toSuiAddress as toSuiAddress3,
900
- transferAllowlistCap
899
+ transferPublisherCap
901
900
  } from "@arcadiasystems/morse-sdk";
902
901
 
903
902
  // src/cli/context.ts
904
903
  import {
904
+ buildRecipientFileEventTypes,
905
905
  DefaultSealAdapter,
906
906
  DefaultWalrusReadAdapter,
907
907
  DefaultWalrusWriteAdapter,
908
908
  HttpAggregatorReadAdapter,
909
909
  KeypairAdapter,
910
910
  morseConfig,
911
- RpcFilesReader,
912
- RpcPublicationReader
911
+ RpcPublicationReader,
912
+ RpcRecipientFilesReader
913
913
  } from "@arcadiasystems/morse-sdk";
914
914
  import { SuiGrpcClient } from "@mysten/sui/grpc";
915
+ import { SuiJsonRpcClient } from "@mysten/sui/jsonRpc";
915
916
  async function buildReadContext(command) {
916
917
  const opts = globalOptions(command);
917
918
  const output = outputFor(command);
@@ -952,14 +953,9 @@ async function buildFilesReadContext(command) {
952
953
  const base = await buildReadContext(command);
953
954
  return {
954
955
  ...base,
955
- filesReader: RpcFilesReader.fromMorseConfig(base.config, base.client)
956
- };
957
- }
958
- async function buildAllowlistWriteContext(command) {
959
- const base = await buildWriteContext(command);
960
- return {
961
- ...base,
962
- filesReader: RpcFilesReader.fromMorseConfig(base.config, base.client)
956
+ filesReader: RpcRecipientFilesReader.fromConfig(base.client, {
957
+ packageId: base.config.packageId
958
+ })
963
959
  };
964
960
  }
965
961
  async function buildContentContext(command) {
@@ -1020,12 +1016,77 @@ async function buildFileDownloadContext(command, opts = {}) {
1020
1016
  }
1021
1017
  return {
1022
1018
  ...base,
1023
- filesReader: RpcFilesReader.fromMorseConfig(base.config, base.client),
1019
+ filesReader: RpcRecipientFilesReader.fromConfig(base.client, {
1020
+ packageId: base.config.packageId
1021
+ }),
1024
1022
  walrusRead: walrusReadAdapter(base, network, Boolean(opts.viaAggregator)),
1025
1023
  seal: DefaultSealAdapter.fromMorseConfig(base.config, {}, base.client),
1026
1024
  unlockSigner: () => resolveSigner(base.settings.account, process.env, base.signal)
1027
1025
  };
1028
1026
  }
1027
+ async function buildFileListContext(command, opts = {}) {
1028
+ const base = await buildReadContext(command);
1029
+ const originPackageId = base.config.recipientFileEventOriginPackageId;
1030
+ if (originPackageId === undefined) {
1031
+ throw new CliError("File listing is unavailable on this network: no recipientFileEventOriginPackageId in the config. Use testnet, or supply a config that sets it.", ExitCode.Usage);
1032
+ }
1033
+ const events = new SuiJsonRpcClient({
1034
+ network: base.settings.network,
1035
+ url: opts.indexerUrl ?? base.config.rpcUrl
1036
+ });
1037
+ return {
1038
+ ...base,
1039
+ filesReader: RpcRecipientFilesReader.fromConfig(base.client, {
1040
+ packageId: base.config.packageId
1041
+ }),
1042
+ events,
1043
+ eventTypes: buildRecipientFileEventTypes(originPackageId)
1044
+ };
1045
+ }
1046
+
1047
+ // src/cli/target.ts
1048
+ import { toPublicationId } from "@arcadiasystems/morse-sdk";
1049
+ var OBJECT_ID = /^0x[0-9a-f]{1,64}$/i;
1050
+ var SLUG_PAGE_LIMIT = 50;
1051
+ async function resolvePublication(ctx, override) {
1052
+ const value = override ?? ctx.settings.publication;
1053
+ if (value === undefined) {
1054
+ throw new UsageError("No publication selected. Pass --publication <slug|id> or run `morse use <slug|id>`.");
1055
+ }
1056
+ if (OBJECT_ID.test(value)) {
1057
+ return toPublicationId(value.toLowerCase());
1058
+ }
1059
+ return resolveSlug(ctx, value);
1060
+ }
1061
+ function resolveCollection(ctx, override) {
1062
+ const value = override ?? ctx.settings.collection;
1063
+ if (value === undefined) {
1064
+ throw new UsageError("No collection selected. Pass --collection <name> or run `morse use <slug|id> <collection>`.");
1065
+ }
1066
+ return value;
1067
+ }
1068
+ async function resolveSlug(ctx, slug) {
1069
+ if (ctx.ownerAddress === undefined) {
1070
+ throw new UsageError(`Cannot resolve the slug "${slug}" without an active account. Pass the publication id, or select an account.`);
1071
+ }
1072
+ ctx.output.info(`Resolving slug "${slug}" among owned publications...`);
1073
+ let cursor;
1074
+ do {
1075
+ const page = await ctx.reader.listPublicationsOwnedBy(ctx.ownerAddress, {
1076
+ limit: SLUG_PAGE_LIMIT,
1077
+ signal: ctx.signal,
1078
+ ...cursor === undefined ? {} : { cursor }
1079
+ });
1080
+ for (const owned of page.results) {
1081
+ const publication = await ctx.reader.getPublication(owned.publicationId, ctx.signal);
1082
+ if (publication.slug === slug) {
1083
+ return publication.id;
1084
+ }
1085
+ }
1086
+ cursor = page.nextCursor ?? undefined;
1087
+ } while (cursor !== undefined);
1088
+ throw new UsageError(`No publication with slug "${slug}" owned by the active account. Pass the publication id instead.`);
1089
+ }
1029
1090
 
1030
1091
  // src/format/ids.ts
1031
1092
  var HEAD = 6;
@@ -1094,36 +1155,30 @@ function renderEntryList(entries) {
1094
1155
  return entries.map((entry) => `#${entry.id} ${entry.name} (${entry.revisions.length} revisions)`).join(`
1095
1156
  `);
1096
1157
  }
1097
- function renderAllowlist(allowlist) {
1098
- const members = allowlist.members.length === 0 ? "(none)" : allowlist.members.map((m) => ` ${m}`).join(`
1099
- `);
1100
- return [
1101
- `${allowlist.name} (${shortId(allowlist.id)})`,
1102
- `members (${allowlist.members.length}):`,
1103
- members
1104
- ].join(`
1105
- `);
1106
- }
1107
- function renderAllowlistCapList(caps) {
1108
- if (caps.length === 0) {
1109
- return "No allowlist caps held by this address.";
1158
+ function renderFileList(items) {
1159
+ if (items.length === 0) {
1160
+ return "No files.";
1110
1161
  }
1111
- return caps.map((cap) => `${cap.id} allowlist ${cap.allowlistId}`).join(`
1162
+ const header = "id name size recipients created";
1163
+ const rows = items.map((file) => {
1164
+ const created = new Date(file.createdAtMs).toISOString().slice(0, 10);
1165
+ return `${shortId(file.id)} ${file.name} ${file.size} ${file.members.length} ${created}`;
1166
+ });
1167
+ return [header, ...rows].join(`
1112
1168
  `);
1113
1169
  }
1114
- function renderEncryptedFile(file) {
1115
- const lines = [
1170
+ function renderRecipientFile(file) {
1171
+ const recipients = file.members.length === 0 ? "(none)" : file.members.map((m) => ` ${m}`).join(`
1172
+ `);
1173
+ return [
1116
1174
  `${file.name} (${shortId(file.id)})`,
1117
1175
  `contentType: ${file.contentType}`,
1118
1176
  `size: ${file.size}`,
1119
- `encrypted: ${file.encrypted}`,
1120
1177
  `blobId: ${file.blobId}`,
1121
- `owner: ${file.owner}`
1122
- ];
1123
- if (file.allowlistId !== null) {
1124
- lines.push(`allowlist: ${file.allowlistId}`);
1125
- }
1126
- return lines.join(`
1178
+ `owner: ${file.owner}`,
1179
+ `recipients (${file.members.length}):`,
1180
+ recipients
1181
+ ].join(`
1127
1182
  `);
1128
1183
  }
1129
1184
  function headLabel(value) {
@@ -1148,11 +1203,11 @@ function publisherCapOption(command) {
1148
1203
  function ownerCapOption(command) {
1149
1204
  return command.option("--owner-cap <id>", "OwnerCap ID (auto-resolved if omitted)");
1150
1205
  }
1151
- function allowlistOption(command) {
1152
- return command.option("-a, --allowlist <id>", "Allowlist object id");
1206
+ function collect(value, previous) {
1207
+ return [...previous, value];
1153
1208
  }
1154
- function allowlistCapOption(command) {
1155
- return command.option("--cap <id>", "Allowlist admin Cap id (auto-resolved from owned caps if omitted)");
1209
+ function recipientOption(command) {
1210
+ return command.option("-r, --recipient <addr>", "Recipient address allowed to decrypt (repeatable); the sender is always added", collect, []);
1156
1211
  }
1157
1212
  function viaAggregatorOption(command) {
1158
1213
  return command.option("--via-aggregator", "Fetch content via the Walrus aggregator HTTP service. More reliable when storage nodes are flaky; no client-side blob verification.");
@@ -1163,7 +1218,6 @@ function contentOptions(command) {
1163
1218
 
1164
1219
  // src/commands/resolve.ts
1165
1220
  import {
1166
- toAllowlistCapId,
1167
1221
  toOwnerCapId,
1168
1222
  toPublisherCapId
1169
1223
  } from "@arcadiasystems/morse-sdk";
@@ -1206,25 +1260,6 @@ async function resolvePublisherCap(reader, address, publicationId, override, sig
1206
1260
  } while (cursor !== undefined);
1207
1261
  throw new CliError(`No PublisherCap for ${publicationId} held by ${address}. Pass --publisher-cap, or check that the active account holds one.`, ExitCode.NotFound);
1208
1262
  }
1209
- async function resolveAllowlistCap(filesReader, address, allowlistId, override, signal) {
1210
- if (override !== undefined) {
1211
- return toAllowlistCapId(override);
1212
- }
1213
- let cursor;
1214
- do {
1215
- const page = await filesReader.listAllowlistCapsOwnedBy(address, {
1216
- limit: PAGE_LIMIT,
1217
- signal,
1218
- ...cursor === undefined ? {} : { cursor }
1219
- });
1220
- const match = page.results.find((c) => c.allowlistId === allowlistId);
1221
- if (match !== undefined) {
1222
- return match.id;
1223
- }
1224
- cursor = page.nextCursor ?? undefined;
1225
- } while (cursor !== undefined);
1226
- throw new CliError(`No allowlist Cap for ${allowlistId} held by ${address}. Pass --cap, or check that the active account administers it.`, ExitCode.NotFound);
1227
- }
1228
1263
 
1229
1264
  // src/commands/shared.ts
1230
1265
  import { StorageMode } from "@arcadiasystems/morse-sdk";
@@ -1249,7 +1284,7 @@ function parseByteSize(value, name) {
1249
1284
  if (!/^\d+$/.test(value)) {
1250
1285
  throw new UsageError(`${name} must be a non-negative integer, got "${value}".`);
1251
1286
  }
1252
- return BigInt(value);
1287
+ return Number(value);
1253
1288
  }
1254
1289
  function parseId(value, name) {
1255
1290
  const id = Number(value);
@@ -1259,191 +1294,9 @@ function parseId(value, name) {
1259
1294
  return id;
1260
1295
  }
1261
1296
 
1262
- // src/commands/allowlist.ts
1263
- function requireAllowlistId(value) {
1264
- if (value === undefined) {
1265
- throw new UsageError("Pass --allowlist <id>.");
1266
- }
1267
- return toAllowlistId(value);
1268
- }
1269
- async function runAllowlistCreate(ctx, options) {
1270
- ctx.output.info(`Creating allowlist "${options.name}"...`);
1271
- const result = await createAllowlist(ctx.adapter, ctx.config, {
1272
- name: options.name,
1273
- signal: ctx.signal
1274
- });
1275
- const human = [
1276
- `Created allowlist "${options.name}" (${result.allowlistId})`,
1277
- ` cap: ${result.capId}`,
1278
- ` tx: ${result.digest}`,
1279
- "Save the cap id: it is the admin token for managing members."
1280
- ].join(`
1281
- `);
1282
- ctx.output.result(human, result);
1283
- }
1284
- async function runAllowlistAddMember(ctx, member, options) {
1285
- const allowlistId = requireAllowlistId(options.allowlist);
1286
- const memberAddress = toSuiAddress3(member);
1287
- const capId = await resolveAllowlistCap(ctx.filesReader, ctx.address, allowlistId, options.cap, ctx.signal);
1288
- const result = await addMember(ctx.adapter, ctx.config, {
1289
- allowlistId,
1290
- capId,
1291
- member: memberAddress,
1292
- signal: ctx.signal
1293
- });
1294
- ctx.output.result(`Added ${memberAddress} to allowlist ${shortId(allowlistId)}. (tx: ${result.digest})`, result);
1295
- }
1296
- async function runAllowlistRemoveMember(ctx, member, options) {
1297
- const allowlistId = requireAllowlistId(options.allowlist);
1298
- const memberAddress = toSuiAddress3(member);
1299
- const capId = await resolveAllowlistCap(ctx.filesReader, ctx.address, allowlistId, options.cap, ctx.signal);
1300
- const result = await removeMember(ctx.adapter, ctx.config, {
1301
- allowlistId,
1302
- capId,
1303
- member: memberAddress,
1304
- signal: ctx.signal
1305
- });
1306
- ctx.output.result(`Removed ${memberAddress} from allowlist ${shortId(allowlistId)}. (tx: ${result.digest})`, result);
1307
- }
1308
- async function runAllowlistTransferCap(ctx, recipient, options, gopts) {
1309
- const allowlistId = requireAllowlistId(options.allowlist);
1310
- const to = toSuiAddress3(recipient);
1311
- const proceed = await confirm(`Transfer admin of allowlist ${shortId(allowlistId)} to ${to}? You will lose member-management rights.`, { assumeYes: Boolean(gopts.yes), signal: ctx.signal });
1312
- if (!proceed) {
1313
- cancelled();
1314
- }
1315
- ctx.output.info("Resolving allowlist Cap...");
1316
- const capId = await resolveAllowlistCap(ctx.filesReader, ctx.address, allowlistId, options.cap, ctx.signal);
1317
- const result = await transferAllowlistCap(ctx.adapter, ctx.config, {
1318
- capId,
1319
- recipient: to,
1320
- signal: ctx.signal
1321
- });
1322
- ctx.output.result(`Transferred allowlist admin to ${to}. (tx: ${result.digest})`, result);
1323
- }
1324
- async function runAllowlistDelete(ctx, options, gopts) {
1325
- const allowlistId = requireAllowlistId(options.allowlist);
1326
- const proceed = await confirm(`Delete allowlist ${shortId(allowlistId)}? Files gated by it become permanently undecryptable.`, { assumeYes: Boolean(gopts.yes), signal: ctx.signal });
1327
- if (!proceed) {
1328
- cancelled();
1329
- }
1330
- ctx.output.info("Resolving allowlist Cap...");
1331
- const capId = await resolveAllowlistCap(ctx.filesReader, ctx.address, allowlistId, options.cap, ctx.signal);
1332
- const result = await deleteAllowlist(ctx.adapter, ctx.config, {
1333
- allowlistId,
1334
- capId,
1335
- signal: ctx.signal
1336
- });
1337
- ctx.output.result(`Deleted allowlist ${allowlistId}. (tx: ${result.digest})`, result);
1338
- }
1339
- async function runAllowlistGet(ctx, target) {
1340
- const result = await ctx.filesReader.getAllowlist(toAllowlistId(target), ctx.signal);
1341
- ctx.output.result(renderAllowlist(result), result);
1342
- }
1343
- async function runAllowlistListCaps(ctx, address, options) {
1344
- const holder = address === undefined ? ctx.ownerAddress : toSuiAddress3(address);
1345
- if (holder === undefined) {
1346
- throw new UsageError("No address given and no active account. Pass an address or import an account.");
1347
- }
1348
- const page = await ctx.filesReader.listAllowlistCapsOwnedBy(holder, {
1349
- signal: ctx.signal,
1350
- ...options.limit === undefined ? {} : { limit: parseLimit(options.limit) },
1351
- ...options.cursor === undefined ? {} : { cursor: options.cursor }
1352
- });
1353
- if (page.nextCursor !== null) {
1354
- ctx.output.info(`More results: pass --cursor "${page.nextCursor}"`);
1355
- }
1356
- ctx.output.result(renderAllowlistCapList(page.results), {
1357
- results: page.results,
1358
- nextCursor: page.nextCursor
1359
- });
1360
- }
1361
- function registerAllowlistCommands(program) {
1362
- const allowlist = program.command("allowlist").description("Manage allowlists that gate decryption of encrypted files");
1363
- allowlist.command("create").description("Create an allowlist and transfer its admin cap to you").requiredOption("-n, --name <name>", "Allowlist name").action(async (options, command) => {
1364
- await runAllowlistCreate(await buildWriteContext(command), options);
1365
- });
1366
- const addMemberCmd = allowlist.command("add-member <member>").description("Add a wallet address to an allowlist");
1367
- allowlistCapOption(allowlistOption(addMemberCmd)).action(async (member, options, command) => {
1368
- await runAllowlistAddMember(await buildAllowlistWriteContext(command), member, options);
1369
- });
1370
- const removeMemberCmd = allowlist.command("remove-member <member>").description("Remove a wallet address from an allowlist");
1371
- allowlistCapOption(allowlistOption(removeMemberCmd)).action(async (member, options, command) => {
1372
- await runAllowlistRemoveMember(await buildAllowlistWriteContext(command), member, options);
1373
- });
1374
- const transferCmd = allowlist.command("transfer-cap <recipient>").description("Transfer allowlist admin rights to another address");
1375
- allowlistCapOption(allowlistOption(transferCmd)).action(async (recipient, options, command) => {
1376
- await runAllowlistTransferCap(await buildAllowlistWriteContext(command), recipient, options, globalOptions(command));
1377
- });
1378
- const deleteCmd = allowlist.command("delete").description("Delete an allowlist (dependent files become undecryptable)");
1379
- allowlistCapOption(allowlistOption(deleteCmd)).action(async (options, command) => {
1380
- await runAllowlistDelete(await buildAllowlistWriteContext(command), options, globalOptions(command));
1381
- });
1382
- allowlist.command("get <allowlist>").description("Fetch an allowlist's name and members").action(async (target, _options, command) => {
1383
- await runAllowlistGet(await buildFilesReadContext(command), target);
1384
- });
1385
- allowlist.command("list-caps [address]").description("List allowlist admin caps held by an address (default: the active account)").option("--limit <n>", "Maximum results per page").option("--cursor <cursor>", "Continue from a previous page cursor").action(async (address, options, command) => {
1386
- await runAllowlistListCaps(await buildFilesReadContext(command), address, options);
1387
- });
1388
- }
1389
-
1390
- // src/commands/cap.ts
1391
- import {
1392
- destroyPublisherCap,
1393
- issuePublisherCap,
1394
- revokePublisherCap,
1395
- toPublisherCapId as toPublisherCapId2,
1396
- toSuiAddress as toSuiAddress4,
1397
- transferPublisherCap
1398
- } from "@arcadiasystems/morse-sdk";
1399
-
1400
- // src/cli/target.ts
1401
- import { toPublicationId } from "@arcadiasystems/morse-sdk";
1402
- var OBJECT_ID = /^0x[0-9a-f]{1,64}$/i;
1403
- var SLUG_PAGE_LIMIT = 50;
1404
- async function resolvePublication(ctx, override) {
1405
- const value = override ?? ctx.settings.publication;
1406
- if (value === undefined) {
1407
- throw new UsageError("No publication selected. Pass --publication <slug|id> or run `morse use <slug|id>`.");
1408
- }
1409
- if (OBJECT_ID.test(value)) {
1410
- return toPublicationId(value.toLowerCase());
1411
- }
1412
- return resolveSlug(ctx, value);
1413
- }
1414
- function resolveCollection(ctx, override) {
1415
- const value = override ?? ctx.settings.collection;
1416
- if (value === undefined) {
1417
- throw new UsageError("No collection selected. Pass --collection <name> or run `morse use <slug|id> <collection>`.");
1418
- }
1419
- return value;
1420
- }
1421
- async function resolveSlug(ctx, slug) {
1422
- if (ctx.ownerAddress === undefined) {
1423
- throw new UsageError(`Cannot resolve the slug "${slug}" without an active account. Pass the publication id, or select an account.`);
1424
- }
1425
- ctx.output.info(`Resolving slug "${slug}" among owned publications...`);
1426
- let cursor;
1427
- do {
1428
- const page = await ctx.reader.listPublicationsOwnedBy(ctx.ownerAddress, {
1429
- limit: SLUG_PAGE_LIMIT,
1430
- signal: ctx.signal,
1431
- ...cursor === undefined ? {} : { cursor }
1432
- });
1433
- for (const owned of page.results) {
1434
- const publication = await ctx.reader.getPublication(owned.publicationId, ctx.signal);
1435
- if (publication.slug === slug) {
1436
- return publication.id;
1437
- }
1438
- }
1439
- cursor = page.nextCursor ?? undefined;
1440
- } while (cursor !== undefined);
1441
- throw new UsageError(`No publication with slug "${slug}" owned by the active account. Pass the publication id instead.`);
1442
- }
1443
-
1444
1297
  // src/commands/cap.ts
1445
1298
  async function runCapList(ctx, address, options) {
1446
- const holder = address === undefined ? ctx.ownerAddress : toSuiAddress4(address);
1299
+ const holder = address === undefined ? ctx.ownerAddress : toSuiAddress3(address);
1447
1300
  if (holder === undefined) {
1448
1301
  throw new UsageError("No address given and no active account. Pass an address or import an account.");
1449
1302
  }
@@ -1462,7 +1315,7 @@ async function runCapList(ctx, address, options) {
1462
1315
  }
1463
1316
  async function runCapIssue(ctx, holder, options) {
1464
1317
  const id = await resolvePublication(ctx, options.publication);
1465
- const holderAddress = toSuiAddress4(holder);
1318
+ const holderAddress = toSuiAddress3(holder);
1466
1319
  ctx.output.info("Resolving OwnerCap...");
1467
1320
  const ownerCapId = await resolveOwnerCap(ctx.reader, ctx.address, id, options.ownerCap, ctx.signal);
1468
1321
  const result = await issuePublisherCap(ctx.adapter, ctx.config, {
@@ -1506,7 +1359,7 @@ async function runCapDestroy(ctx, publisherCapId, options, gopts) {
1506
1359
  }
1507
1360
  async function runCapTransfer(ctx, publisherCapId, recipient, gopts) {
1508
1361
  const capId = toPublisherCapId2(publisherCapId);
1509
- const to = toSuiAddress4(recipient);
1362
+ const to = toSuiAddress3(recipient);
1510
1363
  const proceed = await confirm(`Transfer PublisherCap ${capId} to ${to}?`, {
1511
1364
  assumeYes: Boolean(gopts.yes),
1512
1365
  signal: ctx.signal
@@ -1993,22 +1846,57 @@ function registerEntryCommands(program) {
1993
1846
 
1994
1847
  // src/commands/file.ts
1995
1848
  import {
1996
- buildAllowlistSealId,
1997
- createEncryptedFile,
1998
- createPublicFile,
1999
- deleteFile,
2000
- toAllowlistId as toAllowlistId2,
1849
+ addRecipient,
1850
+ buildRecipientFileSealId,
1851
+ createEncryptedRecipientFile,
1852
+ createRecipientFile,
1853
+ deleteRecipientFile,
1854
+ reconcileRecipientFilesAccessibleBy,
1855
+ reconcileRecipientFilesOwnedBy,
1856
+ removeRecipient,
2001
1857
  toBlobObjectId,
2002
- toEncryptedFileId,
2003
- toSuiAddress as toSuiAddress5,
1858
+ toRecipientFileId,
1859
+ toSuiAddress as toSuiAddress4,
2004
1860
  toWalrusBlobId,
2005
- transferFileOwnership,
2006
- updateFileMetadata,
2007
- uploadEncryptedFileFromBytes,
2008
- uploadPublicFileFromBytes
1861
+ transferRecipientFileOwnership,
1862
+ updateRecipientFileMetadata,
1863
+ uploadEncryptedRecipientFileFromBytes,
1864
+ uploadRecipientFileFromBytes
2009
1865
  } from "@arcadiasystems/morse-sdk";
2010
1866
  import { SessionKey as SessionKey2 } from "@mysten/seal";
2011
1867
 
1868
+ // src/cli/events.ts
1869
+ var PAGE_LIMIT2 = 50;
1870
+ async function fetchEventStream(client, eventType, signal) {
1871
+ const out = [];
1872
+ let cursor;
1873
+ for (;; ) {
1874
+ const page = await client.queryEvents({
1875
+ query: { MoveEventType: eventType },
1876
+ limit: PAGE_LIMIT2,
1877
+ order: "descending",
1878
+ ...cursor === undefined ? {} : { cursor },
1879
+ ...signal === undefined ? {} : { signal }
1880
+ });
1881
+ for (const event of page.data) {
1882
+ out.push({
1883
+ type: event.type,
1884
+ json: event.parsedJson ?? {},
1885
+ timestampMs: event.timestampMs == null ? 0 : Number(event.timestampMs)
1886
+ });
1887
+ }
1888
+ if (!page.hasNextPage || page.nextCursor === null || page.nextCursor === undefined) {
1889
+ break;
1890
+ }
1891
+ cursor = page.nextCursor;
1892
+ }
1893
+ return out;
1894
+ }
1895
+ async function fetchEventStreams(client, eventTypes, signal) {
1896
+ const streams = await Promise.all(eventTypes.map((type) => fetchEventStream(client, type, signal)));
1897
+ return streams.flat();
1898
+ }
1899
+
2012
1900
  // src/format/hex.ts
2013
1901
  var HEX = /^[0-9a-fA-F]+$/;
2014
1902
  function encodeHex(bytes) {
@@ -2030,9 +1918,35 @@ function decodeHex(value) {
2030
1918
  return bytes;
2031
1919
  }
2032
1920
 
1921
+ // src/format/share.ts
1922
+ var VERSION = "mf1";
1923
+ function b64url(bytes) {
1924
+ return Buffer.from(bytes).toString("base64url");
1925
+ }
1926
+ function encodeShare(fileId, prefix, nonce) {
1927
+ return [
1928
+ VERSION,
1929
+ Buffer.from(fileId, "utf8").toString("base64url"),
1930
+ b64url(prefix),
1931
+ b64url(nonce)
1932
+ ].join(".");
1933
+ }
1934
+ function decodeShare(value) {
1935
+ const parts = value.split(".");
1936
+ if (parts.length !== 4 || parts[0] !== VERSION) {
1937
+ throw new UsageError(`Invalid share string: expected "${VERSION}.<fileId>.<prefix>.<nonce>".`);
1938
+ }
1939
+ const fileId = Buffer.from(parts[1] ?? "", "base64url").toString("utf8");
1940
+ const prefix = new Uint8Array(Buffer.from(parts[2] ?? "", "base64url"));
1941
+ const nonce = new Uint8Array(Buffer.from(parts[3] ?? "", "base64url"));
1942
+ if (fileId.length === 0 || prefix.length === 0 || nonce.length === 0) {
1943
+ throw new UsageError("Invalid share string: a bundled field was empty.");
1944
+ }
1945
+ return { fileId, prefix, nonce };
1946
+ }
1947
+
2033
1948
  // src/commands/file.ts
2034
1949
  var SESSION_KEY_TTL_MIN2 = 10;
2035
- var SEAL_NONCE_BYTES2 = 16;
2036
1950
  function uploadProgress(output) {
2037
1951
  const labels = {
2038
1952
  encrypting: "Encrypting...",
@@ -2048,42 +1962,47 @@ function uploadProgress(output) {
2048
1962
  };
2049
1963
  }
2050
1964
  async function runFileGet(ctx, target) {
2051
- const result = await ctx.filesReader.getEncryptedFile(toEncryptedFileId(target), ctx.signal);
2052
- ctx.output.result(renderEncryptedFile(result), result);
1965
+ const result = await ctx.filesReader.getRecipientFile(toRecipientFileId(target), ctx.signal);
1966
+ ctx.output.result(renderRecipientFile(result), result);
2053
1967
  }
2054
1968
  async function runFileRegister(ctx, options) {
2055
- if (options.allowlist === undefined && options.public !== true) {
2056
- throw new UsageError("Pass --allowlist <id> to register an encrypted file, or --public for a world-readable one.");
1969
+ if (options.public && options.encrypted) {
1970
+ throw new UsageError("Pass --public or --encrypted, not both.");
2057
1971
  }
1972
+ const recipients = (options.recipient ?? []).map((r) => toSuiAddress4(r));
2058
1973
  const blobId = toWalrusBlobId(options.blobId);
2059
1974
  const size = parseByteSize(options.size, "--size");
2060
1975
  const blobObjectId = options.blobObjectId === undefined ? undefined : toBlobObjectId(options.blobObjectId);
2061
- if (options.allowlist !== undefined) {
2062
- const result2 = await createEncryptedFile(ctx.adapter, ctx.config, {
2063
- allowlistId: toAllowlistId2(options.allowlist),
2064
- blobId,
2065
- ...blobObjectId === undefined ? {} : { blobObjectId },
2066
- name: options.name,
2067
- contentType: options.contentType,
2068
- size,
2069
- signal: ctx.signal
2070
- });
2071
- ctx.output.result(`Registered encrypted file ${result2.fileId}. (tx: ${result2.digest})`, result2);
2072
- return;
2073
- }
2074
- const result = await createPublicFile(ctx.adapter, ctx.config, {
1976
+ const common = {
2075
1977
  blobId,
2076
1978
  ...blobObjectId === undefined ? {} : { blobObjectId },
2077
1979
  name: options.name,
2078
1980
  contentType: options.contentType,
2079
1981
  size,
1982
+ recipients,
2080
1983
  signal: ctx.signal
2081
- });
2082
- ctx.output.result(`Registered public file ${result.fileId}. (tx: ${result.digest})`, result);
1984
+ };
1985
+ if (options.encrypted) {
1986
+ if (options.sealPrefix === undefined) {
1987
+ throw new UsageError("--encrypted requires --seal-prefix <hex> (the prefix the blob was encrypted under).");
1988
+ }
1989
+ const result = await createEncryptedRecipientFile(ctx.adapter, ctx.config, {
1990
+ ...common,
1991
+ sealIdPrefix: decodeHex(options.sealPrefix)
1992
+ });
1993
+ ctx.output.result(`Registered encrypted file ${result.fileId}. (tx: ${result.digest})`, result);
1994
+ return;
1995
+ }
1996
+ if (options.public) {
1997
+ const result = await createRecipientFile(ctx.adapter, ctx.config, common);
1998
+ ctx.output.result(`Registered public file ${result.fileId}. (tx: ${result.digest})`, result);
1999
+ return;
2000
+ }
2001
+ throw new UsageError("Pass --public for a world-readable file, or --encrypted --seal-prefix <hex>.");
2083
2002
  }
2084
2003
  async function runFileUpdate(ctx, target, options) {
2085
- const result = await updateFileMetadata(ctx.adapter, ctx.config, {
2086
- fileId: toEncryptedFileId(target),
2004
+ const result = await updateRecipientFileMetadata(ctx.adapter, ctx.config, {
2005
+ fileId: toRecipientFileId(target),
2087
2006
  name: options.name,
2088
2007
  contentType: options.contentType,
2089
2008
  signal: ctx.signal
@@ -2091,13 +2010,13 @@ async function runFileUpdate(ctx, target, options) {
2091
2010
  ctx.output.result(`Updated file ${shortId(target)} metadata. (tx: ${result.digest})`, result);
2092
2011
  }
2093
2012
  async function runFileTransferOwnership(ctx, target, newOwner, gopts) {
2094
- const fileId = toEncryptedFileId(target);
2095
- const to = toSuiAddress5(newOwner);
2096
- const proceed = await confirm(`Transfer metadata ownership of file ${shortId(fileId)} to ${to}? Decryption access is governed separately by the allowlist.`, { assumeYes: Boolean(gopts.yes), signal: ctx.signal });
2013
+ const fileId = toRecipientFileId(target);
2014
+ const to = toSuiAddress4(newOwner);
2015
+ const proceed = await confirm(`Transfer ownership of file ${shortId(fileId)} to ${to}? Recipient access is governed separately by the recipient list.`, { assumeYes: Boolean(gopts.yes), signal: ctx.signal });
2097
2016
  if (!proceed) {
2098
2017
  cancelled();
2099
2018
  }
2100
- const result = await transferFileOwnership(ctx.adapter, ctx.config, {
2019
+ const result = await transferRecipientFileOwnership(ctx.adapter, ctx.config, {
2101
2020
  fileId,
2102
2021
  newOwner: to,
2103
2022
  signal: ctx.signal
@@ -2105,76 +2024,140 @@ async function runFileTransferOwnership(ctx, target, newOwner, gopts) {
2105
2024
  ctx.output.result(`Transferred file ownership to ${to}. (tx: ${result.digest})`, result);
2106
2025
  }
2107
2026
  async function runFileDelete(ctx, target, gopts) {
2108
- const fileId = toEncryptedFileId(target);
2109
- const proceed = await confirm(`Delete file metadata ${shortId(fileId)}? The Walrus blob is not deleted; it expires on its own lease.`, { assumeYes: Boolean(gopts.yes), signal: ctx.signal });
2027
+ const fileId = toRecipientFileId(target);
2028
+ const proceed = await confirm(`Delete file ${shortId(fileId)}? The Walrus blob is not deleted; it expires on its own lease.`, { assumeYes: Boolean(gopts.yes), signal: ctx.signal });
2110
2029
  if (!proceed) {
2111
2030
  cancelled();
2112
2031
  }
2113
- const result = await deleteFile(ctx.adapter, ctx.config, {
2032
+ const result = await deleteRecipientFile(ctx.adapter, ctx.config, {
2114
2033
  fileId,
2115
2034
  signal: ctx.signal
2116
2035
  });
2117
2036
  ctx.output.result(`Deleted file ${fileId}. (tx: ${result.digest})`, result);
2118
2037
  }
2038
+ async function runFileRecipientAdd(ctx, target, recipient) {
2039
+ const fileId = toRecipientFileId(target);
2040
+ const member = toSuiAddress4(recipient);
2041
+ const result = await addRecipient(ctx.adapter, ctx.config, {
2042
+ fileId,
2043
+ recipient: member,
2044
+ signal: ctx.signal
2045
+ });
2046
+ ctx.output.result(`Added ${member} to file ${shortId(fileId)}. (tx: ${result.digest})`, result);
2047
+ }
2048
+ async function runFileRecipientRemove(ctx, target, recipient) {
2049
+ const fileId = toRecipientFileId(target);
2050
+ const member = toSuiAddress4(recipient);
2051
+ const result = await removeRecipient(ctx.adapter, ctx.config, {
2052
+ fileId,
2053
+ recipient: member,
2054
+ signal: ctx.signal
2055
+ });
2056
+ ctx.output.result(`Removed ${member} from file ${shortId(fileId)}. (tx: ${result.digest})`, result);
2057
+ }
2058
+ async function runFileRecipientList(ctx, target) {
2059
+ const file = await ctx.filesReader.getRecipientFile(toRecipientFileId(target), ctx.signal);
2060
+ const human = file.members.length === 0 ? "No recipients." : file.members.join(`
2061
+ `);
2062
+ ctx.output.result(human, { fileId: file.id, recipients: file.members });
2063
+ }
2119
2064
  async function runFileUpload(ctx, path, options) {
2120
- if (options.allowlist === undefined && options.public !== true) {
2121
- throw new UsageError("Pass --allowlist <id> to upload an encrypted file, or --public for a world-readable one.");
2065
+ if (options.public && options.encrypt) {
2066
+ throw new UsageError("Pass --public or --encrypt, not both.");
2067
+ }
2068
+ const recipients = (options.recipient ?? []).map((r) => toSuiAddress4(r));
2069
+ if (options.public && recipients.length > 0) {
2070
+ throw new UsageError("--recipient applies to encrypted uploads; a public file is readable by anyone.");
2071
+ }
2072
+ if (!options.public && !options.encrypt && recipients.length === 0) {
2073
+ throw new UsageError("Pass --public for a world-readable file, or --encrypt (optionally with --recipient <addr>).");
2122
2074
  }
2123
2075
  const epochs = parsePositiveInt(options.epochs ?? "3", "--epochs");
2124
2076
  const bytes = await readContentBytes({ file: path });
2125
2077
  const contentType = resolveContentType(options.contentType, path);
2126
2078
  const upload = { epochs, deletable: true };
2127
2079
  const onProgress = uploadProgress(ctx.output);
2128
- if (options.allowlist !== undefined) {
2129
- const allowlistId = toAllowlistId2(options.allowlist);
2130
- const sealId = buildAllowlistSealId(allowlistId, crypto.getRandomValues(new Uint8Array(SEAL_NONCE_BYTES2)));
2131
- const result2 = await uploadEncryptedFileFromBytes(ctx.adapter, ctx.config, {
2080
+ if (options.public) {
2081
+ const result2 = await uploadRecipientFileFromBytes(ctx.adapter, ctx.config, {
2132
2082
  walrus: ctx.walrus,
2133
- seal: ctx.seal,
2134
- allowlistId,
2135
- sealId,
2136
- plaintext: bytes,
2083
+ bytes,
2084
+ recipients: [],
2137
2085
  name: options.name,
2138
2086
  contentType,
2139
2087
  upload,
2140
2088
  signal: ctx.signal,
2141
2089
  onProgress
2142
2090
  });
2143
- const sealIdHex = encodeHex(sealId);
2144
- const human2 = [
2145
- `Uploaded encrypted file ${result2.fileId}`,
2146
- ` blobId: ${result2.blobId}`,
2147
- ` sealId: ${sealIdHex}`,
2148
- ` tx: ${result2.digest}`,
2149
- "Save the seal id: decrypting later needs it plus allowlist membership."
2150
- ].join(`
2151
- `);
2152
- ctx.output.result(human2, { ...result2, sealId: sealIdHex });
2091
+ const aggregator = ctx.config.walrusEndpoints.aggregator;
2092
+ const viewUrl = aggregator.length > 0 ? `${aggregator}/v1/blobs/${result2.blobId}` : undefined;
2093
+ const human2 = viewUrl === undefined ? `Uploaded public file ${result2.fileId}. (tx: ${result2.digest})` : `Uploaded public file ${result2.fileId}. (tx: ${result2.digest})
2094
+ view: ${viewUrl}`;
2095
+ ctx.output.result(human2, { ...result2, viewUrl: viewUrl ?? null });
2153
2096
  return;
2154
2097
  }
2155
- const result = await uploadPublicFileFromBytes(ctx.adapter, ctx.config, {
2098
+ const result = await uploadEncryptedRecipientFileFromBytes(ctx.adapter, ctx.config, {
2156
2099
  walrus: ctx.walrus,
2157
- bytes,
2100
+ seal: ctx.seal,
2101
+ plaintext: bytes,
2102
+ recipients,
2158
2103
  name: options.name,
2159
2104
  contentType,
2160
2105
  upload,
2161
2106
  signal: ctx.signal,
2162
2107
  onProgress
2163
2108
  });
2164
- const aggregator = ctx.config.walrusEndpoints.aggregator;
2165
- const viewUrl = aggregator.length > 0 ? `${aggregator}/v1/blobs/${result.blobId}` : undefined;
2166
- const human = viewUrl === undefined ? `Uploaded public file ${result.fileId}. (tx: ${result.digest})` : `Uploaded public file ${result.fileId}. (tx: ${result.digest})
2167
- view: ${viewUrl}`;
2168
- ctx.output.result(human, { ...result, viewUrl: viewUrl ?? null });
2169
- }
2170
- async function decryptFile(ctx, file, ciphertext, sealIdHex) {
2171
- if (sealIdHex === undefined) {
2172
- throw new UsageError("This file is encrypted; pass --seal-id <hex> (printed when the file was uploaded).");
2109
+ const prefixHex = encodeHex(result.sealIdPrefix);
2110
+ const nonceHex = encodeHex(result.sealNonce);
2111
+ const share = encodeShare(result.fileId, result.sealIdPrefix, result.sealNonce);
2112
+ const human = [
2113
+ `Uploaded encrypted file ${result.fileId}`,
2114
+ ` blobId: ${result.blobId}`,
2115
+ ` share: ${share}`,
2116
+ ` tx: ${result.digest}`,
2117
+ "Give the share string to recipients; they need it (plus membership) to decrypt."
2118
+ ].join(`
2119
+ `);
2120
+ ctx.output.result(human, {
2121
+ fileId: result.fileId,
2122
+ blobId: result.blobId,
2123
+ blobObjectId: result.blobObjectId,
2124
+ digest: result.digest,
2125
+ gasUsedMist: result.gasUsedMist,
2126
+ sealIdPrefix: prefixHex,
2127
+ sealNonce: nonceHex,
2128
+ share
2129
+ });
2130
+ }
2131
+ function resolveDownloadTarget(positional, options) {
2132
+ if (options.share !== undefined) {
2133
+ const decoded = decodeShare(options.share);
2134
+ if (positional !== undefined && String(toRecipientFileId(positional)) !== String(toRecipientFileId(decoded.fileId))) {
2135
+ throw new UsageError("The <file> argument does not match the file id in --share.");
2136
+ }
2137
+ return {
2138
+ fileId: decoded.fileId,
2139
+ decrypt: { prefix: decoded.prefix, nonce: decoded.nonce }
2140
+ };
2141
+ }
2142
+ if (positional === undefined) {
2143
+ throw new UsageError("Pass a <file> id, or --share <string>.");
2173
2144
  }
2174
- if (file.allowlistId === null) {
2175
- throw new UsageError(`File ${file.id} is marked encrypted but carries no allowlist; it cannot be decrypted.`);
2145
+ if (options.prefix !== undefined || options.nonce !== undefined) {
2146
+ if (options.prefix === undefined || options.nonce === undefined) {
2147
+ throw new UsageError("Decrypting needs both --prefix and --nonce (or use --share).");
2148
+ }
2149
+ return {
2150
+ fileId: positional,
2151
+ decrypt: {
2152
+ prefix: decodeHex(options.prefix),
2153
+ nonce: decodeHex(options.nonce)
2154
+ }
2155
+ };
2176
2156
  }
2177
- const sealId = decodeHex(sealIdHex);
2157
+ return { fileId: positional };
2158
+ }
2159
+ async function decryptRecipientFile(ctx, fileId, ciphertext, prefix, nonce) {
2160
+ const sealId = buildRecipientFileSealId(prefix, nonce);
2178
2161
  const { keypair, address } = await ctx.unlockSigner();
2179
2162
  ctx.output.info("Signing a SessionKey with the active account...");
2180
2163
  const sessionKey = await SessionKey2.create({
@@ -2184,24 +2167,29 @@ async function decryptFile(ctx, file, ciphertext, sealIdHex) {
2184
2167
  signer: keypair,
2185
2168
  suiClient: ctx.client
2186
2169
  });
2187
- return ctx.seal.decryptUnderAllowlist(ciphertext, {
2170
+ return ctx.seal.decryptUnderRecipientFile(ciphertext, {
2171
+ sessionKey,
2188
2172
  sealId,
2189
- allowlistId: file.allowlistId,
2190
- sessionKey
2173
+ fileId
2191
2174
  });
2192
2175
  }
2193
- async function runFileDownload(ctx, target, options) {
2176
+ async function runFileDownload(ctx, positional, options) {
2194
2177
  if (ctx.output.isJson && options.out === undefined) {
2195
2178
  throw new UsageError("Downloading content to stdout is not supported in --json mode; pass --out <path>.");
2196
2179
  }
2197
- const file = await ctx.filesReader.getEncryptedFile(toEncryptedFileId(target), ctx.signal);
2180
+ const target = resolveDownloadTarget(positional, options);
2181
+ const fileId = toRecipientFileId(target.fileId);
2182
+ const file = await ctx.filesReader.getRecipientFile(fileId, ctx.signal);
2183
+ if (target.decrypt === undefined && file.members.length > 0) {
2184
+ ctx.output.warn("This file has a recipient list, so its bytes are likely encrypted. Pass --share (or --prefix and --nonce) to decrypt; writing the raw bytes as-is.");
2185
+ }
2198
2186
  ctx.output.info("Fetching content from Walrus...");
2199
2187
  const bytes = await ctx.walrusRead.readBlob(file.blobId, {
2200
2188
  signal: ctx.signal
2201
2189
  });
2202
2190
  let content = bytes;
2203
- if (file.encrypted) {
2204
- content = await decryptFile(ctx, file, bytes, options.sealId);
2191
+ if (target.decrypt !== undefined) {
2192
+ content = await decryptRecipientFile(ctx, fileId, bytes, target.decrypt.prefix, target.decrypt.nonce);
2205
2193
  }
2206
2194
  if (options.out !== undefined) {
2207
2195
  await writeFileContents(options.out, content);
@@ -2216,13 +2204,57 @@ async function runFileDownload(ctx, target, options) {
2216
2204
  }
2217
2205
  process.stdout.write(content);
2218
2206
  }
2207
+ async function runFileList(ctx, options) {
2208
+ const limit = options.limit === undefined ? undefined : parseLimit(options.limit);
2209
+ const address = options.address === undefined ? ctx.ownerAddress : toSuiAddress4(options.address);
2210
+ if (address === undefined) {
2211
+ throw new UsageError("No address given and no active account. Pass --address or import an account.");
2212
+ }
2213
+ const types = ctx.eventTypes;
2214
+ let summaries;
2215
+ if (options.accessible) {
2216
+ ctx.output.info("Fetching recipient and file events...");
2217
+ const events = await fetchEventStreams(ctx.events, [
2218
+ types.RecipientFileCreated,
2219
+ types.RecipientAdded,
2220
+ types.RecipientRemoved,
2221
+ types.RecipientFileDeleted
2222
+ ], ctx.signal);
2223
+ summaries = reconcileRecipientFilesAccessibleBy(events, address, types);
2224
+ } else {
2225
+ ctx.output.info("Fetching file events...");
2226
+ const events = await fetchEventStreams(ctx.events, [
2227
+ types.RecipientFileCreated,
2228
+ types.RecipientFileOwnershipTransferred,
2229
+ types.RecipientFileDeleted
2230
+ ], ctx.signal);
2231
+ summaries = reconcileRecipientFilesOwnedBy(events, address, types);
2232
+ }
2233
+ const limited = limit === undefined ? summaries : summaries.slice(0, limit);
2234
+ let items = limited;
2235
+ if (options.hydrate) {
2236
+ ctx.output.info(`Hydrating ${limited.length} file(s)...`);
2237
+ const full = [];
2238
+ for (const summary of limited) {
2239
+ const record = await ctx.filesReader.getRecipientFile(summary.id, ctx.signal);
2240
+ full.push({ ...record, kind: "full" });
2241
+ }
2242
+ items = full;
2243
+ }
2244
+ ctx.output.result(renderFileList(items), items);
2245
+ }
2219
2246
  function registerFileCommands(program) {
2220
- const file = program.command("file").description("Upload, register, read, and manage files on Walrus");
2221
- const upload = file.command("upload <path>").description("Encrypt (or not), upload to Walrus, and register a file").requiredOption("-n, --name <name>", "File name").option("--public", "Upload a world-readable (unencrypted) file").option("--content-type <mime>", "MIME content type (inferred from the path if omitted)").option("--epochs <n>", "Walrus storage epochs", "3");
2222
- allowlistOption(upload).action(async (path, options, command) => {
2247
+ const file = program.command("file").description("Upload, register, read, list, and manage files on Walrus");
2248
+ const upload = file.command("upload <path>").description("Encrypt (or not), upload to Walrus, and register a file").requiredOption("-n, --name <name>", "File name").option("--public", "Upload a world-readable (unencrypted) file").option("--encrypt", "Encrypt the file (implied when --recipient is given)").option("--content-type <mime>", "MIME content type (inferred from the path if omitted)").option("--epochs <n>", "Walrus storage epochs", "3");
2249
+ recipientOption(upload).action(async (path, options, command) => {
2223
2250
  await runFileUpload(await buildEncryptContext(command), path, options);
2224
2251
  });
2225
- const download = file.command("download <file>").description("Download a file's content; decrypts in place when encrypted").option("--out <path>", "Write content to a file instead of stdout").option("--seal-id <hex>", "Seal id from upload (required to decrypt an encrypted file)");
2252
+ file.command("list").description("List files owned by (or accessible to) an address, via event indexing").option("--address <addr>", "Address to query (default: the active account)").option("--accessible", "List files you can decrypt as a recipient, not just owned").option("--hydrate", "Fetch full records (one read per file)").option("--limit <n>", "Cap the number of results").option("--indexer-url <url>", "Event source URL (default: the RPC; uses suix_queryEvents, a deprecated Sui endpoint)").action(async (options, command) => {
2253
+ await runFileList(await buildFileListContext(command, {
2254
+ indexerUrl: options.indexerUrl
2255
+ }), options);
2256
+ });
2257
+ const download = file.command("download [file]").description("Download a file's content; decrypts in place when encrypted").option("--out <path>", "Write content to a file instead of stdout").option("--share <string>", "Share string from upload (bundles file id, prefix, nonce)").option("--prefix <hex>", "Seal prefix (with --nonce) to decrypt").option("--nonce <hex>", "Seal nonce (with --prefix) to decrypt");
2226
2258
  viaAggregatorOption(download).action(async (target, options, command) => {
2227
2259
  await runFileDownload(await buildFileDownloadContext(command, {
2228
2260
  viaAggregator: options.viaAggregator
@@ -2231,26 +2263,36 @@ function registerFileCommands(program) {
2231
2263
  file.command("get <file>").description("Fetch a file's on-chain metadata").action(async (target, _options, command) => {
2232
2264
  await runFileGet(await buildFilesReadContext(command), target);
2233
2265
  });
2234
- const register = file.command("register").description("Register on-chain metadata for a blob already on Walrus").requiredOption("--blob-id <id>", "Walrus content id").requiredOption("-n, --name <name>", "File name").requiredOption("--content-type <mime>", "MIME content type").requiredOption("--size <bytes>", "Plaintext byte length").option("--public", "Register a world-readable (unencrypted) file").option("--blob-object-id <id>", "On-chain Walrus Blob object id, if known");
2235
- allowlistOption(register).action(async (options, command) => {
2266
+ const register = file.command("register").description("Register on-chain metadata for a blob already on Walrus").requiredOption("--blob-id <id>", "Walrus content id").requiredOption("-n, --name <name>", "File name").requiredOption("--content-type <mime>", "MIME content type").requiredOption("--size <bytes>", "Plaintext byte length").option("--public", "Register a world-readable (unencrypted) file").option("--encrypted", "Register an encrypted file (needs --seal-prefix)").option("--seal-prefix <hex>", "Seal prefix the blob was encrypted under").option("--blob-object-id <id>", "On-chain Walrus Blob object id, if known");
2267
+ recipientOption(register).action(async (options, command) => {
2236
2268
  await runFileRegister(await buildWriteContext(command), options);
2237
2269
  });
2238
2270
  file.command("update <file>").description("Update a file's name and content type (owner only)").requiredOption("-n, --name <name>", "New file name").requiredOption("--content-type <mime>", "New MIME content type").action(async (target, options, command) => {
2239
2271
  await runFileUpdate(await buildWriteContext(command), target, options);
2240
2272
  });
2241
- file.command("transfer-ownership <file> <newOwner>").description("Transfer a file's metadata-mutation right (not decrypt access)").action(async (target, newOwner, _options, command) => {
2273
+ file.command("transfer-ownership <file> <newOwner>").description("Transfer a file's ownership (not recipient access)").action(async (target, newOwner, _options, command) => {
2242
2274
  await runFileTransferOwnership(await buildWriteContext(command), target, newOwner, globalOptions(command));
2243
2275
  });
2244
2276
  file.command("delete <file>").description("Delete a file's metadata record (does not delete the blob)").action(async (target, _options, command) => {
2245
2277
  await runFileDelete(await buildWriteContext(command), target, globalOptions(command));
2246
2278
  });
2279
+ const recipient = file.command("recipient").description("Manage a file's recipient list");
2280
+ recipient.command("add <file> <address>").description("Allow an address to decrypt the file").action(async (target, address, _options, command) => {
2281
+ await runFileRecipientAdd(await buildWriteContext(command), target, address);
2282
+ });
2283
+ recipient.command("remove <file> <address>").description("Revoke an address's access to the file").action(async (target, address, _options, command) => {
2284
+ await runFileRecipientRemove(await buildWriteContext(command), target, address);
2285
+ });
2286
+ recipient.command("list <file>").description("List the file's recipients").action(async (target, _options, command) => {
2287
+ await runFileRecipientList(await buildFilesReadContext(command), target);
2288
+ });
2247
2289
  }
2248
2290
 
2249
2291
  // src/commands/publication.ts
2250
2292
  import {
2251
2293
  createPublication,
2252
2294
  deletePublication,
2253
- toSuiAddress as toSuiAddress6,
2295
+ toSuiAddress as toSuiAddress5,
2254
2296
  transferOwnership
2255
2297
  } from "@arcadiasystems/morse-sdk";
2256
2298
  async function runPublicationGet(ctx, target) {
@@ -2259,7 +2301,7 @@ async function runPublicationGet(ctx, target) {
2259
2301
  ctx.output.result(renderPublication(result), result);
2260
2302
  }
2261
2303
  async function runPublicationList(ctx, address, options) {
2262
- const owner = address === undefined ? ctx.ownerAddress : toSuiAddress6(address);
2304
+ const owner = address === undefined ? ctx.ownerAddress : toSuiAddress5(address);
2263
2305
  if (owner === undefined) {
2264
2306
  throw new UsageError("No address given and no active account. Pass an address or import an account.");
2265
2307
  }
@@ -2338,7 +2380,7 @@ async function runPublicationDelete(ctx, target, options, gopts) {
2338
2380
  }
2339
2381
  async function runPublicationTransferOwnership(ctx, recipient, options, gopts) {
2340
2382
  const id = await resolvePublication(ctx, options.publication);
2341
- const to = toSuiAddress6(recipient);
2383
+ const to = toSuiAddress5(recipient);
2342
2384
  const proceed = await confirm(`Transfer ownership of ${shortId(id)} to ${to}? You will lose owner control.`, { assumeYes: Boolean(gopts.yes), signal: ctx.signal });
2343
2385
  if (!proceed) {
2344
2386
  cancelled();
@@ -2530,7 +2572,6 @@ function registerCommands(program) {
2530
2572
  registerEntryCommands(program);
2531
2573
  registerRevisionCommands(program);
2532
2574
  registerCapCommands(program);
2533
- registerAllowlistCommands(program);
2534
2575
  registerFileCommands(program);
2535
2576
  }
2536
2577
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcadiasystems/morse-cli",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Command-line interface for the Morse decentralized CMS on Sui.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -55,7 +55,7 @@
55
55
  "prepublishOnly": "bun run check && bun run build"
56
56
  },
57
57
  "dependencies": {
58
- "@arcadiasystems/morse-sdk": "^0.2.0",
58
+ "@arcadiasystems/morse-sdk": "^0.4.0",
59
59
  "@mysten/seal": "1.1.3",
60
60
  "@mysten/sui": "2.16.2",
61
61
  "@mysten/walrus": "1.1.6",