@arcadiasystems/morse-cli 0.3.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,44 @@ 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
+
7
45
  ## [0.3.0] - 2026-06-04
8
46
 
9
47
  Event-based file listing, wrapping the SDK 0.3.0 reconcile helpers. Purely
package/README.md CHANGED
@@ -228,56 +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. |
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). |
253
242
  | `file list [--address <addr>] [--accessible] [--hydrate] [--limit <n>] [--indexer-url <url>]` | List files owned by (or, with `--accessible`, decryptable by) an address. |
254
- | `file get <file>` | Fetch a file's on-chain metadata. |
243
+ | `file get <file>` | Fetch a file's on-chain metadata and recipient list. |
255
244
  | `file update <file> --name <n> --content-type <m>` | Update name and MIME (owner only). |
256
- | `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). |
257
246
  | `file delete <file> [-y]` | Delete the metadata record (the Walrus blob expires on its own lease). |
258
-
259
- Encrypting a file (`file upload -a <allowlist>`) prints a **seal id**. It is not
260
- recoverable from the ciphertext, so save it: `file download` needs it (via
261
- `--seal-id`) plus allowlist membership to decrypt.
262
-
263
- `file list` reconstructs the file set from contract events. Encrypted files are
264
- shared objects with no on-chain owner index, so listing is event-derived, not a
265
- direct query. By default the command reads events via `suix_queryEvents` on the
266
- configured Sui RPC. That endpoint is **deprecated** (Mysten is sunsetting it), so
267
- listing may degrade or stop working on the public RPC over time; point
268
- `--indexer-url <url>` at any source that speaks `suix_queryEvents` (a self-hosted
269
- indexer, a third-party endpoint) to stay in control. Results are best-effort and
270
- eventually consistent (subject to indexer lag and retention). `EncryptedFileSummary`
271
- rows omit `blobId`/`blobObjectId`; add `--hydrate` to fetch the full record per
272
- file (one read each) when you need them.
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.
273
268
 
274
269
  ## Output and scripting
275
270
 
276
271
  - Human-readable output goes to stdout; progress, warnings, and prompts go to
277
272
  stderr. Redirecting stdout yields only the result.
278
273
  - `--json` emits a single JSON document on stdout (nothing else). `bigint` values
279
- (e.g. `gasUsedMist`) are encoded as decimal strings and byte arrays (`sealId`,
280
- 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.
281
277
  - Color is emitted only to a TTY and honors `NO_COLOR` and `FORCE_COLOR`.
282
278
 
283
279
  ## Exit codes
@@ -300,7 +296,7 @@ file (one read each) when you need them.
300
296
  - [`encrypt-decrypt.sh`](./examples/encrypt-decrypt.sh): encrypt with Seal and decrypt back.
301
297
  - [`delegation.sh`](./examples/delegation.sh): issue a PublisherCap to a delegate, then revoke it.
302
298
  - [`ci-noninteractive.sh`](./examples/ci-noninteractive.sh): env-var auth, `--yes`, and `--json` parsing.
303
- - [`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).
304
300
 
305
301
  ## Limitations
306
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.3.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.3.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,28 +889,27 @@ 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 {
905
- buildFilesEventTypes,
904
+ buildRecipientFileEventTypes,
906
905
  DefaultSealAdapter,
907
906
  DefaultWalrusReadAdapter,
908
907
  DefaultWalrusWriteAdapter,
909
908
  HttpAggregatorReadAdapter,
910
909
  KeypairAdapter,
911
910
  morseConfig,
912
- RpcFilesReader,
913
- RpcPublicationReader
911
+ RpcPublicationReader,
912
+ RpcRecipientFilesReader
914
913
  } from "@arcadiasystems/morse-sdk";
915
914
  import { SuiGrpcClient } from "@mysten/sui/grpc";
916
915
  import { SuiJsonRpcClient } from "@mysten/sui/jsonRpc";
@@ -954,14 +953,9 @@ async function buildFilesReadContext(command) {
954
953
  const base = await buildReadContext(command);
955
954
  return {
956
955
  ...base,
957
- filesReader: RpcFilesReader.fromMorseConfig(base.config, base.client)
958
- };
959
- }
960
- async function buildAllowlistWriteContext(command) {
961
- const base = await buildWriteContext(command);
962
- return {
963
- ...base,
964
- filesReader: RpcFilesReader.fromMorseConfig(base.config, base.client)
956
+ filesReader: RpcRecipientFilesReader.fromConfig(base.client, {
957
+ packageId: base.config.packageId
958
+ })
965
959
  };
966
960
  }
967
961
  async function buildContentContext(command) {
@@ -1022,7 +1016,9 @@ async function buildFileDownloadContext(command, opts = {}) {
1022
1016
  }
1023
1017
  return {
1024
1018
  ...base,
1025
- filesReader: RpcFilesReader.fromMorseConfig(base.config, base.client),
1019
+ filesReader: RpcRecipientFilesReader.fromConfig(base.client, {
1020
+ packageId: base.config.packageId
1021
+ }),
1026
1022
  walrusRead: walrusReadAdapter(base, network, Boolean(opts.viaAggregator)),
1027
1023
  seal: DefaultSealAdapter.fromMorseConfig(base.config, {}, base.client),
1028
1024
  unlockSigner: () => resolveSigner(base.settings.account, process.env, base.signal)
@@ -1030,9 +1026,9 @@ async function buildFileDownloadContext(command, opts = {}) {
1030
1026
  }
1031
1027
  async function buildFileListContext(command, opts = {}) {
1032
1028
  const base = await buildReadContext(command);
1033
- const originPackageId = base.config.filesEventOriginPackageId;
1029
+ const originPackageId = base.config.recipientFileEventOriginPackageId;
1034
1030
  if (originPackageId === undefined) {
1035
- throw new CliError("File listing is unavailable on this network: no filesEventOriginPackageId in the config. Use testnet, or supply a config that sets it.", ExitCode.Usage);
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);
1036
1032
  }
1037
1033
  const events = new SuiJsonRpcClient({
1038
1034
  network: base.settings.network,
@@ -1040,12 +1036,58 @@ async function buildFileListContext(command, opts = {}) {
1040
1036
  });
1041
1037
  return {
1042
1038
  ...base,
1043
- filesReader: RpcFilesReader.fromMorseConfig(base.config, base.client),
1039
+ filesReader: RpcRecipientFilesReader.fromConfig(base.client, {
1040
+ packageId: base.config.packageId
1041
+ }),
1044
1042
  events,
1045
- eventTypes: buildFilesEventTypes(originPackageId)
1043
+ eventTypes: buildRecipientFileEventTypes(originPackageId)
1046
1044
  };
1047
1045
  }
1048
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
+ }
1090
+
1049
1091
  // src/format/ids.ts
1050
1092
  var HEAD = 6;
1051
1093
  var TAIL = 4;
@@ -1113,50 +1155,30 @@ function renderEntryList(entries) {
1113
1155
  return entries.map((entry) => `#${entry.id} ${entry.name} (${entry.revisions.length} revisions)`).join(`
1114
1156
  `);
1115
1157
  }
1116
- function renderAllowlist(allowlist) {
1117
- const members = allowlist.members.length === 0 ? "(none)" : allowlist.members.map((m) => ` ${m}`).join(`
1118
- `);
1119
- return [
1120
- `${allowlist.name} (${shortId(allowlist.id)})`,
1121
- `members (${allowlist.members.length}):`,
1122
- members
1123
- ].join(`
1124
- `);
1125
- }
1126
- function renderAllowlistCapList(caps) {
1127
- if (caps.length === 0) {
1128
- return "No allowlist caps held by this address.";
1129
- }
1130
- return caps.map((cap) => `${cap.id} allowlist ${cap.allowlistId}`).join(`
1131
- `);
1132
- }
1133
1158
  function renderFileList(items) {
1134
1159
  if (items.length === 0) {
1135
1160
  return "No files.";
1136
1161
  }
1137
- const header = "id name size encrypted allowlist created";
1162
+ const header = "id name size recipients created";
1138
1163
  const rows = items.map((file) => {
1139
- const allowlist = file.allowlistId === null ? "public" : shortId(file.allowlistId);
1140
1164
  const created = new Date(file.createdAtMs).toISOString().slice(0, 10);
1141
- const encrypted = file.encrypted ? "yes" : "no";
1142
- return `${shortId(file.id)} ${file.name} ${file.size} ${encrypted} ${allowlist} ${created}`;
1165
+ return `${shortId(file.id)} ${file.name} ${file.size} ${file.members.length} ${created}`;
1143
1166
  });
1144
1167
  return [header, ...rows].join(`
1145
1168
  `);
1146
1169
  }
1147
- function renderEncryptedFile(file) {
1148
- const lines = [
1170
+ function renderRecipientFile(file) {
1171
+ const recipients = file.members.length === 0 ? "(none)" : file.members.map((m) => ` ${m}`).join(`
1172
+ `);
1173
+ return [
1149
1174
  `${file.name} (${shortId(file.id)})`,
1150
1175
  `contentType: ${file.contentType}`,
1151
1176
  `size: ${file.size}`,
1152
- `encrypted: ${file.encrypted}`,
1153
1177
  `blobId: ${file.blobId}`,
1154
- `owner: ${file.owner}`
1155
- ];
1156
- if (file.allowlistId !== null) {
1157
- lines.push(`allowlist: ${file.allowlistId}`);
1158
- }
1159
- return lines.join(`
1178
+ `owner: ${file.owner}`,
1179
+ `recipients (${file.members.length}):`,
1180
+ recipients
1181
+ ].join(`
1160
1182
  `);
1161
1183
  }
1162
1184
  function headLabel(value) {
@@ -1181,11 +1203,11 @@ function publisherCapOption(command) {
1181
1203
  function ownerCapOption(command) {
1182
1204
  return command.option("--owner-cap <id>", "OwnerCap ID (auto-resolved if omitted)");
1183
1205
  }
1184
- function allowlistOption(command) {
1185
- return command.option("-a, --allowlist <id>", "Allowlist object id");
1206
+ function collect(value, previous) {
1207
+ return [...previous, value];
1186
1208
  }
1187
- function allowlistCapOption(command) {
1188
- 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, []);
1189
1211
  }
1190
1212
  function viaAggregatorOption(command) {
1191
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.");
@@ -1196,7 +1218,6 @@ function contentOptions(command) {
1196
1218
 
1197
1219
  // src/commands/resolve.ts
1198
1220
  import {
1199
- toAllowlistCapId,
1200
1221
  toOwnerCapId,
1201
1222
  toPublisherCapId
1202
1223
  } from "@arcadiasystems/morse-sdk";
@@ -1239,25 +1260,6 @@ async function resolvePublisherCap(reader, address, publicationId, override, sig
1239
1260
  } while (cursor !== undefined);
1240
1261
  throw new CliError(`No PublisherCap for ${publicationId} held by ${address}. Pass --publisher-cap, or check that the active account holds one.`, ExitCode.NotFound);
1241
1262
  }
1242
- async function resolveAllowlistCap(filesReader, address, allowlistId, override, signal) {
1243
- if (override !== undefined) {
1244
- return toAllowlistCapId(override);
1245
- }
1246
- let cursor;
1247
- do {
1248
- const page = await filesReader.listAllowlistCapsOwnedBy(address, {
1249
- limit: PAGE_LIMIT,
1250
- signal,
1251
- ...cursor === undefined ? {} : { cursor }
1252
- });
1253
- const match = page.results.find((c) => c.allowlistId === allowlistId);
1254
- if (match !== undefined) {
1255
- return match.id;
1256
- }
1257
- cursor = page.nextCursor ?? undefined;
1258
- } while (cursor !== undefined);
1259
- throw new CliError(`No allowlist Cap for ${allowlistId} held by ${address}. Pass --cap, or check that the active account administers it.`, ExitCode.NotFound);
1260
- }
1261
1263
 
1262
1264
  // src/commands/shared.ts
1263
1265
  import { StorageMode } from "@arcadiasystems/morse-sdk";
@@ -1282,7 +1284,7 @@ function parseByteSize(value, name) {
1282
1284
  if (!/^\d+$/.test(value)) {
1283
1285
  throw new UsageError(`${name} must be a non-negative integer, got "${value}".`);
1284
1286
  }
1285
- return BigInt(value);
1287
+ return Number(value);
1286
1288
  }
1287
1289
  function parseId(value, name) {
1288
1290
  const id = Number(value);
@@ -1292,191 +1294,9 @@ function parseId(value, name) {
1292
1294
  return id;
1293
1295
  }
1294
1296
 
1295
- // src/commands/allowlist.ts
1296
- function requireAllowlistId(value) {
1297
- if (value === undefined) {
1298
- throw new UsageError("Pass --allowlist <id>.");
1299
- }
1300
- return toAllowlistId(value);
1301
- }
1302
- async function runAllowlistCreate(ctx, options) {
1303
- ctx.output.info(`Creating allowlist "${options.name}"...`);
1304
- const result = await createAllowlist(ctx.adapter, ctx.config, {
1305
- name: options.name,
1306
- signal: ctx.signal
1307
- });
1308
- const human = [
1309
- `Created allowlist "${options.name}" (${result.allowlistId})`,
1310
- ` cap: ${result.capId}`,
1311
- ` tx: ${result.digest}`,
1312
- "Save the cap id: it is the admin token for managing members."
1313
- ].join(`
1314
- `);
1315
- ctx.output.result(human, result);
1316
- }
1317
- async function runAllowlistAddMember(ctx, member, options) {
1318
- const allowlistId = requireAllowlistId(options.allowlist);
1319
- const memberAddress = toSuiAddress3(member);
1320
- const capId = await resolveAllowlistCap(ctx.filesReader, ctx.address, allowlistId, options.cap, ctx.signal);
1321
- const result = await addMember(ctx.adapter, ctx.config, {
1322
- allowlistId,
1323
- capId,
1324
- member: memberAddress,
1325
- signal: ctx.signal
1326
- });
1327
- ctx.output.result(`Added ${memberAddress} to allowlist ${shortId(allowlistId)}. (tx: ${result.digest})`, result);
1328
- }
1329
- async function runAllowlistRemoveMember(ctx, member, options) {
1330
- const allowlistId = requireAllowlistId(options.allowlist);
1331
- const memberAddress = toSuiAddress3(member);
1332
- const capId = await resolveAllowlistCap(ctx.filesReader, ctx.address, allowlistId, options.cap, ctx.signal);
1333
- const result = await removeMember(ctx.adapter, ctx.config, {
1334
- allowlistId,
1335
- capId,
1336
- member: memberAddress,
1337
- signal: ctx.signal
1338
- });
1339
- ctx.output.result(`Removed ${memberAddress} from allowlist ${shortId(allowlistId)}. (tx: ${result.digest})`, result);
1340
- }
1341
- async function runAllowlistTransferCap(ctx, recipient, options, gopts) {
1342
- const allowlistId = requireAllowlistId(options.allowlist);
1343
- const to = toSuiAddress3(recipient);
1344
- 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 });
1345
- if (!proceed) {
1346
- cancelled();
1347
- }
1348
- ctx.output.info("Resolving allowlist Cap...");
1349
- const capId = await resolveAllowlistCap(ctx.filesReader, ctx.address, allowlistId, options.cap, ctx.signal);
1350
- const result = await transferAllowlistCap(ctx.adapter, ctx.config, {
1351
- capId,
1352
- recipient: to,
1353
- signal: ctx.signal
1354
- });
1355
- ctx.output.result(`Transferred allowlist admin to ${to}. (tx: ${result.digest})`, result);
1356
- }
1357
- async function runAllowlistDelete(ctx, options, gopts) {
1358
- const allowlistId = requireAllowlistId(options.allowlist);
1359
- const proceed = await confirm(`Delete allowlist ${shortId(allowlistId)}? Files gated by it become permanently undecryptable.`, { assumeYes: Boolean(gopts.yes), signal: ctx.signal });
1360
- if (!proceed) {
1361
- cancelled();
1362
- }
1363
- ctx.output.info("Resolving allowlist Cap...");
1364
- const capId = await resolveAllowlistCap(ctx.filesReader, ctx.address, allowlistId, options.cap, ctx.signal);
1365
- const result = await deleteAllowlist(ctx.adapter, ctx.config, {
1366
- allowlistId,
1367
- capId,
1368
- signal: ctx.signal
1369
- });
1370
- ctx.output.result(`Deleted allowlist ${allowlistId}. (tx: ${result.digest})`, result);
1371
- }
1372
- async function runAllowlistGet(ctx, target) {
1373
- const result = await ctx.filesReader.getAllowlist(toAllowlistId(target), ctx.signal);
1374
- ctx.output.result(renderAllowlist(result), result);
1375
- }
1376
- async function runAllowlistListCaps(ctx, address, options) {
1377
- const holder = address === undefined ? ctx.ownerAddress : toSuiAddress3(address);
1378
- if (holder === undefined) {
1379
- throw new UsageError("No address given and no active account. Pass an address or import an account.");
1380
- }
1381
- const page = await ctx.filesReader.listAllowlistCapsOwnedBy(holder, {
1382
- signal: ctx.signal,
1383
- ...options.limit === undefined ? {} : { limit: parseLimit(options.limit) },
1384
- ...options.cursor === undefined ? {} : { cursor: options.cursor }
1385
- });
1386
- if (page.nextCursor !== null) {
1387
- ctx.output.info(`More results: pass --cursor "${page.nextCursor}"`);
1388
- }
1389
- ctx.output.result(renderAllowlistCapList(page.results), {
1390
- results: page.results,
1391
- nextCursor: page.nextCursor
1392
- });
1393
- }
1394
- function registerAllowlistCommands(program) {
1395
- const allowlist = program.command("allowlist").description("Manage allowlists that gate decryption of encrypted files");
1396
- allowlist.command("create").description("Create an allowlist and transfer its admin cap to you").requiredOption("-n, --name <name>", "Allowlist name").action(async (options, command) => {
1397
- await runAllowlistCreate(await buildWriteContext(command), options);
1398
- });
1399
- const addMemberCmd = allowlist.command("add-member <member>").description("Add a wallet address to an allowlist");
1400
- allowlistCapOption(allowlistOption(addMemberCmd)).action(async (member, options, command) => {
1401
- await runAllowlistAddMember(await buildAllowlistWriteContext(command), member, options);
1402
- });
1403
- const removeMemberCmd = allowlist.command("remove-member <member>").description("Remove a wallet address from an allowlist");
1404
- allowlistCapOption(allowlistOption(removeMemberCmd)).action(async (member, options, command) => {
1405
- await runAllowlistRemoveMember(await buildAllowlistWriteContext(command), member, options);
1406
- });
1407
- const transferCmd = allowlist.command("transfer-cap <recipient>").description("Transfer allowlist admin rights to another address");
1408
- allowlistCapOption(allowlistOption(transferCmd)).action(async (recipient, options, command) => {
1409
- await runAllowlistTransferCap(await buildAllowlistWriteContext(command), recipient, options, globalOptions(command));
1410
- });
1411
- const deleteCmd = allowlist.command("delete").description("Delete an allowlist (dependent files become undecryptable)");
1412
- allowlistCapOption(allowlistOption(deleteCmd)).action(async (options, command) => {
1413
- await runAllowlistDelete(await buildAllowlistWriteContext(command), options, globalOptions(command));
1414
- });
1415
- allowlist.command("get <allowlist>").description("Fetch an allowlist's name and members").action(async (target, _options, command) => {
1416
- await runAllowlistGet(await buildFilesReadContext(command), target);
1417
- });
1418
- 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) => {
1419
- await runAllowlistListCaps(await buildFilesReadContext(command), address, options);
1420
- });
1421
- }
1422
-
1423
- // src/commands/cap.ts
1424
- import {
1425
- destroyPublisherCap,
1426
- issuePublisherCap,
1427
- revokePublisherCap,
1428
- toPublisherCapId as toPublisherCapId2,
1429
- toSuiAddress as toSuiAddress4,
1430
- transferPublisherCap
1431
- } from "@arcadiasystems/morse-sdk";
1432
-
1433
- // src/cli/target.ts
1434
- import { toPublicationId } from "@arcadiasystems/morse-sdk";
1435
- var OBJECT_ID = /^0x[0-9a-f]{1,64}$/i;
1436
- var SLUG_PAGE_LIMIT = 50;
1437
- async function resolvePublication(ctx, override) {
1438
- const value = override ?? ctx.settings.publication;
1439
- if (value === undefined) {
1440
- throw new UsageError("No publication selected. Pass --publication <slug|id> or run `morse use <slug|id>`.");
1441
- }
1442
- if (OBJECT_ID.test(value)) {
1443
- return toPublicationId(value.toLowerCase());
1444
- }
1445
- return resolveSlug(ctx, value);
1446
- }
1447
- function resolveCollection(ctx, override) {
1448
- const value = override ?? ctx.settings.collection;
1449
- if (value === undefined) {
1450
- throw new UsageError("No collection selected. Pass --collection <name> or run `morse use <slug|id> <collection>`.");
1451
- }
1452
- return value;
1453
- }
1454
- async function resolveSlug(ctx, slug) {
1455
- if (ctx.ownerAddress === undefined) {
1456
- throw new UsageError(`Cannot resolve the slug "${slug}" without an active account. Pass the publication id, or select an account.`);
1457
- }
1458
- ctx.output.info(`Resolving slug "${slug}" among owned publications...`);
1459
- let cursor;
1460
- do {
1461
- const page = await ctx.reader.listPublicationsOwnedBy(ctx.ownerAddress, {
1462
- limit: SLUG_PAGE_LIMIT,
1463
- signal: ctx.signal,
1464
- ...cursor === undefined ? {} : { cursor }
1465
- });
1466
- for (const owned of page.results) {
1467
- const publication = await ctx.reader.getPublication(owned.publicationId, ctx.signal);
1468
- if (publication.slug === slug) {
1469
- return publication.id;
1470
- }
1471
- }
1472
- cursor = page.nextCursor ?? undefined;
1473
- } while (cursor !== undefined);
1474
- throw new UsageError(`No publication with slug "${slug}" owned by the active account. Pass the publication id instead.`);
1475
- }
1476
-
1477
1297
  // src/commands/cap.ts
1478
1298
  async function runCapList(ctx, address, options) {
1479
- const holder = address === undefined ? ctx.ownerAddress : toSuiAddress4(address);
1299
+ const holder = address === undefined ? ctx.ownerAddress : toSuiAddress3(address);
1480
1300
  if (holder === undefined) {
1481
1301
  throw new UsageError("No address given and no active account. Pass an address or import an account.");
1482
1302
  }
@@ -1495,7 +1315,7 @@ async function runCapList(ctx, address, options) {
1495
1315
  }
1496
1316
  async function runCapIssue(ctx, holder, options) {
1497
1317
  const id = await resolvePublication(ctx, options.publication);
1498
- const holderAddress = toSuiAddress4(holder);
1318
+ const holderAddress = toSuiAddress3(holder);
1499
1319
  ctx.output.info("Resolving OwnerCap...");
1500
1320
  const ownerCapId = await resolveOwnerCap(ctx.reader, ctx.address, id, options.ownerCap, ctx.signal);
1501
1321
  const result = await issuePublisherCap(ctx.adapter, ctx.config, {
@@ -1539,7 +1359,7 @@ async function runCapDestroy(ctx, publisherCapId, options, gopts) {
1539
1359
  }
1540
1360
  async function runCapTransfer(ctx, publisherCapId, recipient, gopts) {
1541
1361
  const capId = toPublisherCapId2(publisherCapId);
1542
- const to = toSuiAddress4(recipient);
1362
+ const to = toSuiAddress3(recipient);
1543
1363
  const proceed = await confirm(`Transfer PublisherCap ${capId} to ${to}?`, {
1544
1364
  assumeYes: Boolean(gopts.yes),
1545
1365
  signal: ctx.signal
@@ -2026,21 +1846,22 @@ function registerEntryCommands(program) {
2026
1846
 
2027
1847
  // src/commands/file.ts
2028
1848
  import {
2029
- buildAllowlistSealId,
2030
- createEncryptedFile,
2031
- createPublicFile,
2032
- deleteFile,
2033
- reconcileFilesAccessibleBy,
2034
- reconcileFilesOwnedBy,
2035
- toAllowlistId as toAllowlistId2,
1849
+ addRecipient,
1850
+ buildRecipientFileSealId,
1851
+ createEncryptedRecipientFile,
1852
+ createRecipientFile,
1853
+ deleteRecipientFile,
1854
+ reconcileRecipientFilesAccessibleBy,
1855
+ reconcileRecipientFilesOwnedBy,
1856
+ removeRecipient,
2036
1857
  toBlobObjectId,
2037
- toEncryptedFileId,
2038
- toSuiAddress as toSuiAddress5,
1858
+ toRecipientFileId,
1859
+ toSuiAddress as toSuiAddress4,
2039
1860
  toWalrusBlobId,
2040
- transferFileOwnership,
2041
- updateFileMetadata,
2042
- uploadEncryptedFileFromBytes,
2043
- uploadPublicFileFromBytes
1861
+ transferRecipientFileOwnership,
1862
+ updateRecipientFileMetadata,
1863
+ uploadEncryptedRecipientFileFromBytes,
1864
+ uploadRecipientFileFromBytes
2044
1865
  } from "@arcadiasystems/morse-sdk";
2045
1866
  import { SessionKey as SessionKey2 } from "@mysten/seal";
2046
1867
 
@@ -2060,8 +1881,8 @@ async function fetchEventStream(client, eventType, signal) {
2060
1881
  for (const event of page.data) {
2061
1882
  out.push({
2062
1883
  type: event.type,
2063
- parsedJson: event.parsedJson,
2064
- timestampMs: event.timestampMs
1884
+ json: event.parsedJson ?? {},
1885
+ timestampMs: event.timestampMs == null ? 0 : Number(event.timestampMs)
2065
1886
  });
2066
1887
  }
2067
1888
  if (!page.hasNextPage || page.nextCursor === null || page.nextCursor === undefined) {
@@ -2097,9 +1918,35 @@ function decodeHex(value) {
2097
1918
  return bytes;
2098
1919
  }
2099
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
+
2100
1948
  // src/commands/file.ts
2101
1949
  var SESSION_KEY_TTL_MIN2 = 10;
2102
- var SEAL_NONCE_BYTES2 = 16;
2103
1950
  function uploadProgress(output) {
2104
1951
  const labels = {
2105
1952
  encrypting: "Encrypting...",
@@ -2115,42 +1962,47 @@ function uploadProgress(output) {
2115
1962
  };
2116
1963
  }
2117
1964
  async function runFileGet(ctx, target) {
2118
- const result = await ctx.filesReader.getEncryptedFile(toEncryptedFileId(target), ctx.signal);
2119
- ctx.output.result(renderEncryptedFile(result), result);
1965
+ const result = await ctx.filesReader.getRecipientFile(toRecipientFileId(target), ctx.signal);
1966
+ ctx.output.result(renderRecipientFile(result), result);
2120
1967
  }
2121
1968
  async function runFileRegister(ctx, options) {
2122
- if (options.allowlist === undefined && options.public !== true) {
2123
- 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.");
2124
1971
  }
1972
+ const recipients = (options.recipient ?? []).map((r) => toSuiAddress4(r));
2125
1973
  const blobId = toWalrusBlobId(options.blobId);
2126
1974
  const size = parseByteSize(options.size, "--size");
2127
1975
  const blobObjectId = options.blobObjectId === undefined ? undefined : toBlobObjectId(options.blobObjectId);
2128
- if (options.allowlist !== undefined) {
2129
- const result2 = await createEncryptedFile(ctx.adapter, ctx.config, {
2130
- allowlistId: toAllowlistId2(options.allowlist),
2131
- blobId,
2132
- ...blobObjectId === undefined ? {} : { blobObjectId },
2133
- name: options.name,
2134
- contentType: options.contentType,
2135
- size,
2136
- signal: ctx.signal
2137
- });
2138
- ctx.output.result(`Registered encrypted file ${result2.fileId}. (tx: ${result2.digest})`, result2);
2139
- return;
2140
- }
2141
- const result = await createPublicFile(ctx.adapter, ctx.config, {
1976
+ const common = {
2142
1977
  blobId,
2143
1978
  ...blobObjectId === undefined ? {} : { blobObjectId },
2144
1979
  name: options.name,
2145
1980
  contentType: options.contentType,
2146
1981
  size,
1982
+ recipients,
2147
1983
  signal: ctx.signal
2148
- });
2149
- 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>.");
2150
2002
  }
2151
2003
  async function runFileUpdate(ctx, target, options) {
2152
- const result = await updateFileMetadata(ctx.adapter, ctx.config, {
2153
- fileId: toEncryptedFileId(target),
2004
+ const result = await updateRecipientFileMetadata(ctx.adapter, ctx.config, {
2005
+ fileId: toRecipientFileId(target),
2154
2006
  name: options.name,
2155
2007
  contentType: options.contentType,
2156
2008
  signal: ctx.signal
@@ -2158,13 +2010,13 @@ async function runFileUpdate(ctx, target, options) {
2158
2010
  ctx.output.result(`Updated file ${shortId(target)} metadata. (tx: ${result.digest})`, result);
2159
2011
  }
2160
2012
  async function runFileTransferOwnership(ctx, target, newOwner, gopts) {
2161
- const fileId = toEncryptedFileId(target);
2162
- const to = toSuiAddress5(newOwner);
2163
- 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 });
2164
2016
  if (!proceed) {
2165
2017
  cancelled();
2166
2018
  }
2167
- const result = await transferFileOwnership(ctx.adapter, ctx.config, {
2019
+ const result = await transferRecipientFileOwnership(ctx.adapter, ctx.config, {
2168
2020
  fileId,
2169
2021
  newOwner: to,
2170
2022
  signal: ctx.signal
@@ -2172,76 +2024,140 @@ async function runFileTransferOwnership(ctx, target, newOwner, gopts) {
2172
2024
  ctx.output.result(`Transferred file ownership to ${to}. (tx: ${result.digest})`, result);
2173
2025
  }
2174
2026
  async function runFileDelete(ctx, target, gopts) {
2175
- const fileId = toEncryptedFileId(target);
2176
- 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 });
2177
2029
  if (!proceed) {
2178
2030
  cancelled();
2179
2031
  }
2180
- const result = await deleteFile(ctx.adapter, ctx.config, {
2032
+ const result = await deleteRecipientFile(ctx.adapter, ctx.config, {
2181
2033
  fileId,
2182
2034
  signal: ctx.signal
2183
2035
  });
2184
2036
  ctx.output.result(`Deleted file ${fileId}. (tx: ${result.digest})`, result);
2185
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
+ }
2186
2064
  async function runFileUpload(ctx, path, options) {
2187
- if (options.allowlist === undefined && options.public !== true) {
2188
- 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>).");
2189
2074
  }
2190
2075
  const epochs = parsePositiveInt(options.epochs ?? "3", "--epochs");
2191
2076
  const bytes = await readContentBytes({ file: path });
2192
2077
  const contentType = resolveContentType(options.contentType, path);
2193
2078
  const upload = { epochs, deletable: true };
2194
2079
  const onProgress = uploadProgress(ctx.output);
2195
- if (options.allowlist !== undefined) {
2196
- const allowlistId = toAllowlistId2(options.allowlist);
2197
- const sealId = buildAllowlistSealId(allowlistId, crypto.getRandomValues(new Uint8Array(SEAL_NONCE_BYTES2)));
2198
- const result2 = await uploadEncryptedFileFromBytes(ctx.adapter, ctx.config, {
2080
+ if (options.public) {
2081
+ const result2 = await uploadRecipientFileFromBytes(ctx.adapter, ctx.config, {
2199
2082
  walrus: ctx.walrus,
2200
- seal: ctx.seal,
2201
- allowlistId,
2202
- sealId,
2203
- plaintext: bytes,
2083
+ bytes,
2084
+ recipients: [],
2204
2085
  name: options.name,
2205
2086
  contentType,
2206
2087
  upload,
2207
2088
  signal: ctx.signal,
2208
2089
  onProgress
2209
2090
  });
2210
- const sealIdHex = encodeHex(sealId);
2211
- const human2 = [
2212
- `Uploaded encrypted file ${result2.fileId}`,
2213
- ` blobId: ${result2.blobId}`,
2214
- ` sealId: ${sealIdHex}`,
2215
- ` tx: ${result2.digest}`,
2216
- "Save the seal id: decrypting later needs it plus allowlist membership."
2217
- ].join(`
2218
- `);
2219
- 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 });
2220
2096
  return;
2221
2097
  }
2222
- const result = await uploadPublicFileFromBytes(ctx.adapter, ctx.config, {
2098
+ const result = await uploadEncryptedRecipientFileFromBytes(ctx.adapter, ctx.config, {
2223
2099
  walrus: ctx.walrus,
2224
- bytes,
2100
+ seal: ctx.seal,
2101
+ plaintext: bytes,
2102
+ recipients,
2225
2103
  name: options.name,
2226
2104
  contentType,
2227
2105
  upload,
2228
2106
  signal: ctx.signal,
2229
2107
  onProgress
2230
2108
  });
2231
- const aggregator = ctx.config.walrusEndpoints.aggregator;
2232
- const viewUrl = aggregator.length > 0 ? `${aggregator}/v1/blobs/${result.blobId}` : undefined;
2233
- const human = viewUrl === undefined ? `Uploaded public file ${result.fileId}. (tx: ${result.digest})` : `Uploaded public file ${result.fileId}. (tx: ${result.digest})
2234
- view: ${viewUrl}`;
2235
- ctx.output.result(human, { ...result, viewUrl: viewUrl ?? null });
2236
- }
2237
- async function decryptFile(ctx, file, ciphertext, sealIdHex) {
2238
- if (sealIdHex === undefined) {
2239
- 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>.");
2240
2144
  }
2241
- if (file.allowlistId === null) {
2242
- 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
+ };
2243
2156
  }
2244
- const sealId = decodeHex(sealIdHex);
2157
+ return { fileId: positional };
2158
+ }
2159
+ async function decryptRecipientFile(ctx, fileId, ciphertext, prefix, nonce) {
2160
+ const sealId = buildRecipientFileSealId(prefix, nonce);
2245
2161
  const { keypair, address } = await ctx.unlockSigner();
2246
2162
  ctx.output.info("Signing a SessionKey with the active account...");
2247
2163
  const sessionKey = await SessionKey2.create({
@@ -2251,24 +2167,29 @@ async function decryptFile(ctx, file, ciphertext, sealIdHex) {
2251
2167
  signer: keypair,
2252
2168
  suiClient: ctx.client
2253
2169
  });
2254
- return ctx.seal.decryptUnderAllowlist(ciphertext, {
2170
+ return ctx.seal.decryptUnderRecipientFile(ciphertext, {
2171
+ sessionKey,
2255
2172
  sealId,
2256
- allowlistId: file.allowlistId,
2257
- sessionKey
2173
+ fileId
2258
2174
  });
2259
2175
  }
2260
- async function runFileDownload(ctx, target, options) {
2176
+ async function runFileDownload(ctx, positional, options) {
2261
2177
  if (ctx.output.isJson && options.out === undefined) {
2262
2178
  throw new UsageError("Downloading content to stdout is not supported in --json mode; pass --out <path>.");
2263
2179
  }
2264
- 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
+ }
2265
2186
  ctx.output.info("Fetching content from Walrus...");
2266
2187
  const bytes = await ctx.walrusRead.readBlob(file.blobId, {
2267
2188
  signal: ctx.signal
2268
2189
  });
2269
2190
  let content = bytes;
2270
- if (file.encrypted) {
2271
- 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);
2272
2193
  }
2273
2194
  if (options.out !== undefined) {
2274
2195
  await writeFileContents(options.out, content);
@@ -2285,26 +2206,29 @@ async function runFileDownload(ctx, target, options) {
2285
2206
  }
2286
2207
  async function runFileList(ctx, options) {
2287
2208
  const limit = options.limit === undefined ? undefined : parseLimit(options.limit);
2288
- const address = options.address === undefined ? ctx.ownerAddress : toSuiAddress5(options.address);
2209
+ const address = options.address === undefined ? ctx.ownerAddress : toSuiAddress4(options.address);
2289
2210
  if (address === undefined) {
2290
2211
  throw new UsageError("No address given and no active account. Pass --address or import an account.");
2291
2212
  }
2292
2213
  const types = ctx.eventTypes;
2293
2214
  let summaries;
2294
2215
  if (options.accessible) {
2295
- ctx.output.info("Fetching membership and file events...");
2216
+ ctx.output.info("Fetching recipient and file events...");
2296
2217
  const events = await fetchEventStreams(ctx.events, [
2297
- types.MemberAdded,
2298
- types.MemberRemoved,
2299
- types.AllowlistDeleted,
2300
- types.FileCreated,
2301
- types.FileDeleted
2218
+ types.RecipientFileCreated,
2219
+ types.RecipientAdded,
2220
+ types.RecipientRemoved,
2221
+ types.RecipientFileDeleted
2302
2222
  ], ctx.signal);
2303
- summaries = reconcileFilesAccessibleBy(events, address, types);
2223
+ summaries = reconcileRecipientFilesAccessibleBy(events, address, types);
2304
2224
  } else {
2305
2225
  ctx.output.info("Fetching file events...");
2306
- const events = await fetchEventStreams(ctx.events, [types.FileCreated, types.FileOwnershipTransferred, types.FileDeleted], ctx.signal);
2307
- summaries = reconcileFilesOwnedBy(events, address, types);
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);
2308
2232
  }
2309
2233
  const limited = limit === undefined ? summaries : summaries.slice(0, limit);
2310
2234
  let items = limited;
@@ -2312,7 +2236,7 @@ async function runFileList(ctx, options) {
2312
2236
  ctx.output.info(`Hydrating ${limited.length} file(s)...`);
2313
2237
  const full = [];
2314
2238
  for (const summary of limited) {
2315
- const record = await ctx.filesReader.getEncryptedFile(summary.id, ctx.signal);
2239
+ const record = await ctx.filesReader.getRecipientFile(summary.id, ctx.signal);
2316
2240
  full.push({ ...record, kind: "full" });
2317
2241
  }
2318
2242
  items = full;
@@ -2320,17 +2244,17 @@ async function runFileList(ctx, options) {
2320
2244
  ctx.output.result(renderFileList(items), items);
2321
2245
  }
2322
2246
  function registerFileCommands(program) {
2323
- const file = program.command("file").description("Upload, register, read, and manage files on Walrus");
2324
- 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");
2325
- 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) => {
2326
2250
  await runFileUpload(await buildEncryptContext(command), path, options);
2327
2251
  });
2328
- 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 via allowlist membership, not just owned").option("--hydrate", "Fetch full records (adds blobId; 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) => {
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) => {
2329
2253
  await runFileList(await buildFileListContext(command, {
2330
2254
  indexerUrl: options.indexerUrl
2331
2255
  }), options);
2332
2256
  });
2333
- 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)");
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");
2334
2258
  viaAggregatorOption(download).action(async (target, options, command) => {
2335
2259
  await runFileDownload(await buildFileDownloadContext(command, {
2336
2260
  viaAggregator: options.viaAggregator
@@ -2339,26 +2263,36 @@ function registerFileCommands(program) {
2339
2263
  file.command("get <file>").description("Fetch a file's on-chain metadata").action(async (target, _options, command) => {
2340
2264
  await runFileGet(await buildFilesReadContext(command), target);
2341
2265
  });
2342
- 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");
2343
- 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) => {
2344
2268
  await runFileRegister(await buildWriteContext(command), options);
2345
2269
  });
2346
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) => {
2347
2271
  await runFileUpdate(await buildWriteContext(command), target, options);
2348
2272
  });
2349
- 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) => {
2350
2274
  await runFileTransferOwnership(await buildWriteContext(command), target, newOwner, globalOptions(command));
2351
2275
  });
2352
2276
  file.command("delete <file>").description("Delete a file's metadata record (does not delete the blob)").action(async (target, _options, command) => {
2353
2277
  await runFileDelete(await buildWriteContext(command), target, globalOptions(command));
2354
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
+ });
2355
2289
  }
2356
2290
 
2357
2291
  // src/commands/publication.ts
2358
2292
  import {
2359
2293
  createPublication,
2360
2294
  deletePublication,
2361
- toSuiAddress as toSuiAddress6,
2295
+ toSuiAddress as toSuiAddress5,
2362
2296
  transferOwnership
2363
2297
  } from "@arcadiasystems/morse-sdk";
2364
2298
  async function runPublicationGet(ctx, target) {
@@ -2367,7 +2301,7 @@ async function runPublicationGet(ctx, target) {
2367
2301
  ctx.output.result(renderPublication(result), result);
2368
2302
  }
2369
2303
  async function runPublicationList(ctx, address, options) {
2370
- const owner = address === undefined ? ctx.ownerAddress : toSuiAddress6(address);
2304
+ const owner = address === undefined ? ctx.ownerAddress : toSuiAddress5(address);
2371
2305
  if (owner === undefined) {
2372
2306
  throw new UsageError("No address given and no active account. Pass an address or import an account.");
2373
2307
  }
@@ -2446,7 +2380,7 @@ async function runPublicationDelete(ctx, target, options, gopts) {
2446
2380
  }
2447
2381
  async function runPublicationTransferOwnership(ctx, recipient, options, gopts) {
2448
2382
  const id = await resolvePublication(ctx, options.publication);
2449
- const to = toSuiAddress6(recipient);
2383
+ const to = toSuiAddress5(recipient);
2450
2384
  const proceed = await confirm(`Transfer ownership of ${shortId(id)} to ${to}? You will lose owner control.`, { assumeYes: Boolean(gopts.yes), signal: ctx.signal });
2451
2385
  if (!proceed) {
2452
2386
  cancelled();
@@ -2638,7 +2572,6 @@ function registerCommands(program) {
2638
2572
  registerEntryCommands(program);
2639
2573
  registerRevisionCommands(program);
2640
2574
  registerCapCommands(program);
2641
- registerAllowlistCommands(program);
2642
2575
  registerFileCommands(program);
2643
2576
  }
2644
2577
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcadiasystems/morse-cli",
3
- "version": "0.3.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.3.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",