@arcadiasystems/morse-cli 0.9.0 → 0.10.1

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,26 @@ 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.10.1] - 2026-09-10
8
+
9
+ Tooling only. No behaviour changes.
10
+
11
+ ### Added
12
+
13
+ - `bun run docs-gate`, wired into `bun run check` alongside the coverage gate. It fails when the README status banner does not name the current version, or when CHANGELOG's newest entry does not match `package.json`. The banner silently claimed v0.3.0 for two whole releases, and nothing in lint, typecheck, tests or coverage could catch it.
14
+
15
+ ## [0.10.0] - 2026-09-10
16
+
17
+ ### Added
18
+
19
+ - **Encrypted commands work on mainnet when you bring a Seal credential.** Mainnet pins no key servers because every operator there is commercial, and the CLI previously had no way to be pointed at one, so `entry add-encrypted`, `entry decrypt` and `file upload --encrypt` simply could not run there.
20
+
21
+ `MORSE_SEAL_API_KEY` supplies a credential for Mysten's committee, whose object id and aggregator the SDK already exports; `MORSE_SEAL_API_KEY_NAME` overrides the header for operators that want something other than `X-API-Key`. `MORSE_SEAL_KEY_SERVERS` takes a JSON array of `{ objectId, weight }` for a non-committee operator and wins when both are set. Neither is a flag: a credential in argv leaks through `ps` and shell history, the same reason the signing key is not one.
22
+
23
+ - **The credential is checked before anything is encrypted.** Seal reads key-server public keys from chain but fetches key shares from the operator, so a wrong credential would let `encrypt` succeed and fail only at `decrypt`, leaving the user paying to store content nobody can read. That exact shape shipped once as the mainnet Seal default, and an unchecked API key would have reintroduced it user-side. Only an explicit `401` or `403` refuses; any other outcome, including the check being unreachable, proceeds, because a flaky pre-flight must never block a working key.
24
+
25
+ Verified live: a full encrypt and decrypt round trip on testnet through `MORSE_SEAL_KEY_SERVERS` (the same code path a credential takes), and a bogus `MORSE_SEAL_API_KEY` refused against the real Mysten aggregator with the pre-flight message rather than producing unreadable ciphertext.
26
+
7
27
  ## [0.9.0] - 2026-09-10
8
28
  ### Fixed (config state, found in a whole-package bug hunt)
9
29
 
package/README.md CHANGED
@@ -6,15 +6,14 @@ 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.9.0. Mainnet and testnet are both supported for public content;
9
+ > Status: v0.10.1. Mainnet and testnet are both supported for public content;
10
10
  > the command surface is stable.
11
11
  >
12
- > **Encrypted commands are testnet-only.** `entry add-encrypted`, `entry
13
- > decrypt`, `file upload --encrypt` (and `--recipient`, which implies it), and
14
- > decrypting a downloaded file all need Seal key servers. Testnet has an open
15
- > set; mainnet does not, because every mainnet Seal operator is commercial, and
16
- > the CLI has no flag or env var for supplying one. These commands exit 2 on
17
- > mainnet with an explanation.
12
+ > **Encrypted commands need Seal key servers.** Testnet has an open set, so
13
+ > they work there out of the box. Mainnet has none, because every mainnet Seal
14
+ > operator is commercial: supply your own credential via `MORSE_SEAL_API_KEY`
15
+ > (see [Seal on mainnet](#seal-on-mainnet)), or these commands exit 2 with an
16
+ > explanation.
18
17
  >
19
18
  > Everything that does not encrypt works on both networks, including
20
19
  > `file upload --public`, `file download` of a public file, and the whole
@@ -114,6 +113,9 @@ Environment variables (override the config file, overridden by flags):
114
113
  | `MORSE_NETWORK` | `--network` | `mainnet`, `testnet`, or `localnet`. |
115
114
  | `MORSE_WALRUS_UPLOAD_RELAY` | `--upload-relay` | Upload through a Walrus relay instead of the direct fanout. `auto` picks the canonical relay for the network. |
116
115
  | `MORSE_WALRUS_MAX_TIP` | `--max-tip` | Cap the relay's per-upload tip, in MIST. Defaults to 10000000 (0.01 SUI). |
116
+ | `MORSE_SEAL_API_KEY` | (none) | Credential for Mysten's Seal committee, enabling encrypted commands on mainnet. |
117
+ | `MORSE_SEAL_API_KEY_NAME` | (none) | Header name for that credential. Defaults to `X-API-Key`. |
118
+ | `MORSE_SEAL_KEY_SERVERS` | (none) | JSON array of `{ objectId, weight }` for a non-committee Seal operator. Wins over `MORSE_SEAL_API_KEY`. |
117
119
  | `MORSE_RPC_URL` | `--rpc` | Sui RPC URL override. |
118
120
  | `MORSE_ADDRESS` | (no flag) | Active account address, selecting which keystore to use. |
119
121
  | `MORSE_PUBLICATION` | `-P, --publication` | Active publication id. |
@@ -372,6 +374,41 @@ for works. These are aliases, not separate commands:
372
374
 
373
375
  `publication` also answers to `pub`.
374
376
 
377
+ ## Seal on mainnet
378
+
379
+ Encrypted commands need Seal key servers. Testnet pins an open set, so they
380
+ work there with no setup. Mainnet pins none: every mainnet Seal operator is
381
+ commercial, so you bring your own.
382
+
383
+ The usual route is a credential for Mysten's decentralized committee, which
384
+ the CLI already knows the object id and aggregator for:
385
+
386
+ ```sh
387
+ export MORSE_SEAL_API_KEY=your-key-here
388
+ morse --network mainnet file upload ./secret.pdf --encrypt -r 0xrecipient
389
+ ```
390
+
391
+ For an operator that is not the committee, give the servers directly:
392
+
393
+ ```sh
394
+ export MORSE_SEAL_KEY_SERVERS='[{"objectId":"0x...","weight":1}]'
395
+ ```
396
+
397
+ Entries may carry `aggregatorUrl`, `apiKeyName` and `apiKey`; Seal requires
398
+ `aggregatorUrl` on a committee-mode server and forbids it on an independent
399
+ one. `MORSE_SEAL_KEY_SERVERS` takes precedence when both are set.
400
+
401
+ **Neither is a flag, deliberately.** A credential in argv leaks through `ps`
402
+ and shell history, the same reason the signing key is never a flag either.
403
+
404
+ Before encrypting anything, the CLI checks the credential against the operator
405
+ and refuses if it comes back `401` or `403`. This matters more than it looks:
406
+ Seal reads key-server public keys from chain but fetches key *shares* from the
407
+ operator, so a bad credential would let encryption succeed and fail only at
408
+ decryption, leaving you paying to store content nobody can ever read. Any other
409
+ outcome, including the check being offline, proceeds normally, because a flaky
410
+ check must never block a working key.
411
+
375
412
  ## When uploads fail: the upload relay
376
413
 
377
414
  A Walrus upload pushes slivers to every storage node in the committee at once,
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.9.0",
151
+ version: "0.10.1",
152
152
  description: "Command-line interface for the Morse decentralized CMS on Sui.",
153
153
  license: "MIT",
154
154
  type: "module",
@@ -191,6 +191,7 @@ var package_default = {
191
191
  start: "bun src/index.ts",
192
192
  build: "bun build src/index.ts --target node --packages external --outfile dist/index.js",
193
193
  typecheck: "tsc --noEmit",
194
+ "docs-gate": "bun scripts/docs-gate.ts",
194
195
  lint: "biome check .",
195
196
  "lint:fix": "biome check --write .",
196
197
  test: "bun test",
@@ -199,11 +200,11 @@ var package_default = {
199
200
  "test:coverage": "bun test --coverage",
200
201
  "test:e2e": "MORSE_E2E=1 bun test test/e2e/",
201
202
  coverage: "bun scripts/coverage-gate.ts",
202
- check: "tsc --noEmit && biome check . && bun scripts/coverage-gate.ts",
203
+ check: "tsc --noEmit && biome check . && bun scripts/docs-gate.ts && bun scripts/coverage-gate.ts",
203
204
  prepublishOnly: "bun run check && bun run build"
204
205
  },
205
206
  dependencies: {
206
- "@arcadiasystems/morse-sdk": "^0.8.0",
207
+ "@arcadiasystems/morse-sdk": "^0.8.1",
207
208
  "@mysten/seal": "1.1.3",
208
209
  "@mysten/sui": "2.16.2",
209
210
  "@mysten/walrus": "1.1.6",
@@ -989,6 +990,72 @@ import {
989
990
  } from "@arcadiasystems/morse-sdk";
990
991
  import { SuiGrpcClient } from "@mysten/sui/grpc";
991
992
 
993
+ // src/config/seal.ts
994
+ import { MAINNET_SEAL_COMMITTEE } from "@arcadiasystems/morse-sdk";
995
+ var DEFAULT_API_KEY_NAME = "X-API-Key";
996
+ function sealServersFromEnv(env = process.env) {
997
+ const explicit = env.MORSE_SEAL_KEY_SERVERS;
998
+ if (explicit !== undefined && explicit.length > 0) {
999
+ return { servers: parseKeyServers(explicit) };
1000
+ }
1001
+ const apiKey = env.MORSE_SEAL_API_KEY;
1002
+ if (apiKey === undefined || apiKey.length === 0) {
1003
+ return;
1004
+ }
1005
+ const apiKeyName = env.MORSE_SEAL_API_KEY_NAME ?? DEFAULT_API_KEY_NAME;
1006
+ return {
1007
+ servers: MAINNET_SEAL_COMMITTEE.map((server) => ({
1008
+ ...server,
1009
+ apiKeyName,
1010
+ apiKey
1011
+ })),
1012
+ ...MAINNET_SEAL_COMMITTEE[0]?.aggregatorUrl === undefined ? {} : { verifyUrl: MAINNET_SEAL_COMMITTEE[0].aggregatorUrl },
1013
+ apiKeyName,
1014
+ apiKey
1015
+ };
1016
+ }
1017
+ async function assertSealCredentialAccepted(env, fetchImpl = fetch) {
1018
+ if (env.verifyUrl === undefined || env.apiKey === undefined) {
1019
+ return;
1020
+ }
1021
+ let status;
1022
+ try {
1023
+ const response = await fetchImpl(`${env.verifyUrl}/v1/service`, {
1024
+ headers: { [env.apiKeyName ?? DEFAULT_API_KEY_NAME]: env.apiKey }
1025
+ });
1026
+ status = response.status;
1027
+ } catch {
1028
+ return;
1029
+ }
1030
+ if (status === 401 || status === 403) {
1031
+ throw new UsageError(`The Seal operator at ${env.verifyUrl} rejected your credential (HTTP ${status}). Encrypting anyway would produce content you could never decrypt, so this stops here. Check MORSE_SEAL_API_KEY.`);
1032
+ }
1033
+ }
1034
+ function parseKeyServers(raw) {
1035
+ let parsed;
1036
+ try {
1037
+ parsed = JSON.parse(raw);
1038
+ } catch (cause) {
1039
+ throw new UsageError(`MORSE_SEAL_KEY_SERVERS is not valid JSON: ${String(cause)}`);
1040
+ }
1041
+ if (!Array.isArray(parsed) || parsed.length === 0) {
1042
+ throw new UsageError('MORSE_SEAL_KEY_SERVERS must be a non-empty JSON array of { objectId, weight } entries, e.g. [{"objectId":"0x...","weight":1}].');
1043
+ }
1044
+ return parsed.map((entry, index) => {
1045
+ if (typeof entry !== "object" || entry === null) {
1046
+ throw new UsageError(`MORSE_SEAL_KEY_SERVERS[${index}] is not an object.`);
1047
+ }
1048
+ const { objectId, weight } = entry;
1049
+ if (typeof objectId !== "string" || !objectId.startsWith("0x")) {
1050
+ throw new UsageError(`MORSE_SEAL_KEY_SERVERS[${index}].objectId must be a 0x-prefixed object id.`);
1051
+ }
1052
+ if (typeof weight !== "number" || !Number.isInteger(weight) || weight < 1) {
1053
+ throw new UsageError(`MORSE_SEAL_KEY_SERVERS[${index}].weight must be a positive integer.`);
1054
+ }
1055
+ return entry;
1056
+ });
1057
+ }
1058
+
992
1059
  // src/cli/graphql-events.ts
993
1060
  function canonicalGraphqlUrl(network) {
994
1061
  if (network === "mainnet" || network === "testnet") {
@@ -1114,11 +1181,12 @@ async function buildContentContext(command) {
1114
1181
  walrus
1115
1182
  };
1116
1183
  }
1117
- function assertSealAvailable(config) {
1118
- if (config.sealKeyServers.length > 0) {
1119
- return;
1184
+ function buildSeal(config, client) {
1185
+ const fromEnv = sealServersFromEnv();
1186
+ if (fromEnv === undefined && config.sealKeyServers.length === 0) {
1187
+ throw new CliError(`Encrypted commands are not available on ${config.network}: it pins no Seal key servers, because every operator there is commercial. Set MORSE_SEAL_API_KEY if you have a credential for Mysten's committee, or MORSE_SEAL_KEY_SERVERS to a JSON array for another operator. Otherwise use --network testnet.`, ExitCode.Usage);
1120
1188
  }
1121
- throw new CliError(`Encrypted commands are not available on ${config.network}. Seal key servers there are operated commercially and the CLI cannot yet be pointed at one. Use --network testnet, or drive @arcadiasystems/morse-sdk directly with your own seal.serverConfigs.`, ExitCode.Usage);
1189
+ return DefaultSealAdapter.fromMorseConfig(config, fromEnv === undefined ? {} : { serverConfigs: fromEnv.servers }, client);
1122
1190
  }
1123
1191
  function walrusWriteConfig(base, network) {
1124
1192
  const host = resolveUploadRelay(base.settings.uploadRelay, network);
@@ -1133,6 +1201,10 @@ function walrusWriteConfig(base, network) {
1133
1201
  }
1134
1202
  async function buildEncryptContext(command) {
1135
1203
  const ctx = await buildContentContext(command);
1204
+ const fromEnv = sealServersFromEnv();
1205
+ if (fromEnv !== undefined) {
1206
+ await assertSealCredentialAccepted(fromEnv);
1207
+ }
1136
1208
  return { ...ctx, seal: lazySeal(ctx) };
1137
1209
  }
1138
1210
  function walrusReadAdapter(base, network, viaAggregator) {
@@ -1161,8 +1233,7 @@ async function buildDecryptContext(command, opts = {}) {
1161
1233
  if (network === "localnet") {
1162
1234
  throw new CliError("Seal decryption is not available on localnet. Use testnet or mainnet.", ExitCode.Usage);
1163
1235
  }
1164
- assertSealAvailable(base.config);
1165
- const seal = DefaultSealAdapter.fromMorseConfig(base.config, {}, base.client);
1236
+ const seal = buildSeal(base.config, base.client);
1166
1237
  return {
1167
1238
  ...base,
1168
1239
  keypair,
@@ -1190,8 +1261,7 @@ async function buildFileDownloadContext(command, opts = {}) {
1190
1261
  function lazySeal(base) {
1191
1262
  let adapter;
1192
1263
  return () => {
1193
- assertSealAvailable(base.config);
1194
- adapter ??= DefaultSealAdapter.fromMorseConfig(base.config, {}, base.client);
1264
+ adapter ??= buildSeal(base.config, base.client);
1195
1265
  return adapter;
1196
1266
  };
1197
1267
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcadiasystems/morse-cli",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
4
4
  "description": "Command-line interface for the Morse decentralized CMS on Sui.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -43,6 +43,7 @@
43
43
  "start": "bun src/index.ts",
44
44
  "build": "bun build src/index.ts --target node --packages external --outfile dist/index.js",
45
45
  "typecheck": "tsc --noEmit",
46
+ "docs-gate": "bun scripts/docs-gate.ts",
46
47
  "lint": "biome check .",
47
48
  "lint:fix": "biome check --write .",
48
49
  "test": "bun test",
@@ -51,11 +52,11 @@
51
52
  "test:coverage": "bun test --coverage",
52
53
  "test:e2e": "MORSE_E2E=1 bun test test/e2e/",
53
54
  "coverage": "bun scripts/coverage-gate.ts",
54
- "check": "tsc --noEmit && biome check . && bun scripts/coverage-gate.ts",
55
+ "check": "tsc --noEmit && biome check . && bun scripts/docs-gate.ts && bun scripts/coverage-gate.ts",
55
56
  "prepublishOnly": "bun run check && bun run build"
56
57
  },
57
58
  "dependencies": {
58
- "@arcadiasystems/morse-sdk": "^0.8.0",
59
+ "@arcadiasystems/morse-sdk": "^0.8.1",
59
60
  "@mysten/seal": "1.1.3",
60
61
  "@mysten/sui": "2.16.2",
61
62
  "@mysten/walrus": "1.1.6",