From b41160f1cb5e96da89d84cc552428e4ed7e8b009 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 17:13:05 +0000 Subject: [PATCH] feat(cli): add nak-style stateless primitives (decode, encode, verify, key) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the first batch of nak parity commands to amy — the army-knife primitives that operate purely on their arguments, with no account or network. They dispatch before account resolution (like `use`), so they run with zero `~/.amy/` state: - `amy decode ENTITY` NIP-19/21 entity -> JSON - `amy encode …` raw parts -> NIP-19 entity - `amy verify [JSON]` id-hash + signature check (reads stdin) - `amy key generate|public` mint a keypair / derive a pubkey All four are thin wrappers over quartz (Nip19Parser, the NIP-19 entities, Event.verifyId/verifySignature, KeyPair) per the cli thin-assembly rule. README + ROADMAP updated with a nak-parity matrix. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY --- cli/README.md | 17 +++ cli/ROADMAP.md | 26 ++++ .../com/vitorpamplona/amethyst/cli/Main.kt | 32 +++++ .../amethyst/cli/commands/DecodeCommand.kt | 87 +++++++++++++ .../amethyst/cli/commands/EncodeCommand.kt | 114 ++++++++++++++++++ .../amethyst/cli/commands/KeyCommands.kt | 80 ++++++++++++ .../amethyst/cli/commands/VerifyCommand.kt | 75 ++++++++++++ 7 files changed, 431 insertions(+) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DecodeCommand.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/EncodeCommand.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyCommands.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/VerifyCommand.kt diff --git a/cli/README.md b/cli/README.md index d5cf211887..df8b6c4228 100644 --- a/cli/README.md +++ b/cli/README.md @@ -197,6 +197,23 @@ $ amy relay publish-lists # broadcast updated kind:10002/10050/10051 ## Commands +### Primitives (stateless — no account or network) + +Army-knife verbs that operate purely on their arguments. They never touch +`~/.amy/`, so they run with zero state — handy for scripting and piping +(`amy decode … | jq`, `… | amy verify`). + +| Command | What it does | +|---|---| +| `amy decode ENTITY` | Decode a NIP-19/21 entity (`npub`/`nsec`/`note`/`nevent`/`nprofile`/`naddr`/`nrelay`/`nembed`) to JSON. Accepts an optional `nostr:` prefix. | +| `amy encode npub HEX` / `nsec HEX` / `note ID` | Encode a single 32-byte hex value into the matching NIP-19 entity. | +| `amy encode nevent ID [--author HEX] [--kind N] [--relay URL[,URL…]]` | Encode an event pointer with optional author/kind/relay hints. | +| `amy encode nprofile HEX [--relay URL[,URL…]]` | Encode a profile pointer with optional relay hints. | +| `amy encode naddr --kind N --pubkey HEX --identifier D [--relay URL[,URL…]]` | Encode an addressable-event (`a` tag) pointer. | +| `amy verify [EVENT-JSON]` | Check an event's id hash and signature. Reads stdin when the argument is omitted or `-`. Reports `id_ok` + `signature_ok` separately. | +| `amy key generate` | Mint a fresh keypair (`nsec` + `npub` + hex). Does not persist — use `init`/`login` for that. | +| `amy key public NSEC\|HEX` | Derive the public key from a secret key. | + ### Identity | Command | What it does | diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 4dd633c015..39971a418c 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -70,6 +70,32 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command · | Notifications feed | 🆕 | | | Search (NIP-50) | 🆕 | | +### `nak` parity — army-knife primitives + +Tracking [`fiatjaf/nak`](https://github.com/fiatjaf/nak)'s command surface. amy +adapts the verbiage where its own conventions differ (`req` → one-shot `fetch` +vs streaming `subscribe`). Stateless verbs run with no account or network. + +| nak command | amy verb | Status | Notes | +|---|---|---|---| +| `decode` | `amy decode` | ✅ | NIP-19/21 → JSON. Quartz `Nip19Parser`. | +| `encode` | `amy encode` | ✅ | npub/nsec/note/nevent/nprofile/naddr. | +| `verify` / `validate` | `amy verify` | ✅ | id-hash + signature, reported separately. | +| `key` | `amy key generate\|public` | ✅ in part · 🆕 | generate + derive done; NIP-49 encrypt/decrypt pending. | +| `event` | `amy event` | 🆕 | build/sign an arbitrary event, optional `--publish`. | +| `publish` | `amy publish` | 🆕 | broadcast a pre-made event JSON. | +| `req` (one-shot) | `amy fetch` | 🆕 | filter → collect-until-EOSE. | +| `req` (stream) | `amy subscribe` | 🆕 | filter → live stream to stdout. | +| `count` | `amy count` | 🆕 | NIP-45. | +| `encrypt` / `decrypt` | `amy encrypt\|decrypt` | 🆕 | raw NIP-44 / NIP-04. | +| `gift` | `amy gift wrap\|unwrap` | 🆕 | NIP-59 primitive (gift-wrap path already used by `dm`). | +| `relay` (NIP-11) | `amy relay info` | 🆕 | amy's `relay` is config today; add an `info` verb. | +| `outbox` | `amy outbox` | 🆕 | NIP-65 relay discovery for a user. | +| `blossom` | `amy blossom` | 🆕 | upload/download/list/delete (client already used by `dm`/`nsite`). | +| `kind` / `nip` | `amy kind` / `amy nip` | 🆕 | reference lookups. | +| `sync` | `amy relay sync` | 🆕 | NIP-77 Negentropy. | +| `bunker` / `serve` / `admin` / `wallet` / `git` / `podcast` / `mcp` / `fs` / `spell` | — | 🆕 (tier 2/3) | larger/niche; some pull new deps. | + --- ## Order of operations diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 5ddad30bd8..1c28fedc2e 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -122,6 +122,24 @@ private suspend fun dispatch(argv: Array): Int { .run(tail) } + // Stateless local primitives (nak-style army-knife verbs). They operate + // purely on their arguments — no identity, no relays, no `~/.amy/` — so + // they dispatch before account resolution and work with zero state. + when (head) { + "decode" -> + return com.vitorpamplona.amethyst.cli.commands.DecodeCommand + .run(tail) + "encode" -> + return com.vitorpamplona.amethyst.cli.commands.EncodeCommand + .run(tail) + "verify" -> + return com.vitorpamplona.amethyst.cli.commands.VerifyCommand + .run(tail) + "key" -> + return com.vitorpamplona.amethyst.cli.commands.KeyCommands + .dispatch(tail) + } + val secrets = SecretStore.from(backendFlag = secretBackendFlag, passphraseFile = passphraseFileFlag) val dataDir = DataDir.resolve(accountFlag = accountFlag, secrets = secrets) @@ -333,6 +351,20 @@ private fun printUsage() { | then ${'$'}AMY_PASSPHRASE, then a TTY prompt. `plaintext` writes the | private key directly into identity.json (still 0600) — dev only. | + |Primitives (stateless — no account or network needed): + | decode ENTITY decode a NIP-19/21 entity (npub|nsec|note|nevent| + | nprofile|naddr|nrelay|nembed) to JSON + | encode npub HEX encode raw parts into a NIP-19 entity: + | encode nsec HEX nevent/nprofile/naddr accept --relay URL[,URL…]; + | encode note ID nevent accepts --author HEX --kind N; + | encode nevent ID [...] naddr needs --kind N --pubkey HEX --identifier D + | encode nprofile HEX [...] + | encode naddr --kind N --pubkey HEX --identifier D [--relay URL[,URL…]] + | verify [EVENT-JSON] check an event's id hash + signature + | (reads stdin when the arg is omitted or `-`) + | key generate mint a fresh keypair (nsec + npub + hex) + | key public NSEC|HEX derive the public key from a secret key + | |Identity: | init [--nsec NSEC] create or import a bare identity (no defaults published) | create [--name NAME] provision a full Amethyst-style account + publish bootstrap events diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DecodeCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DecodeCommand.kt new file mode 100644 index 0000000000..b6a1585d1e --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DecodeCommand.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress +import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NNote +import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay +import com.vitorpamplona.quartz.nip19Bech32.entities.NSec + +/** + * `amy decode ` — turn a NIP-19 / NIP-21 entity into its structured + * parts (nak's `decode`). Local, no network, no account. Accepts an optional + * `nostr:` prefix. + * + * Thin assembly only: all parsing lives in quartz's [Nip19Parser]; this file + * just maps the parsed entity onto amy's result-map output contract. + */ +object DecodeCommand { + fun run(rest: Array): Int { + val args = Args(rest) + val input = args.positional(0, "entity").trim() + + val entity = + Nip19Parser.uriToRoute(input)?.entity + ?: return Output.error("bad_args", "not a recognized NIP-19 entity (npub, nsec, note, nevent, nprofile, naddr, nrelay, nembed)") + + val result: Map = + when (entity) { + is NPub -> mapOf("type" to "npub", "pubkey" to entity.hex) + is NSec -> mapOf("type" to "nsec", "private_key" to entity.hex, "pubkey" to entity.toPubKeyHex()) + is NNote -> mapOf("type" to "note", "id" to entity.hex) + is NProfile -> + mapOf( + "type" to "nprofile", + "pubkey" to entity.hex, + "relays" to entity.relay.map { it.url }, + ) + is NEvent -> + mapOf( + "type" to "nevent", + "id" to entity.hex, + "author" to entity.author, + "kind" to entity.kind, + "relays" to entity.relay.map { it.url }, + ) + is NAddress -> + mapOf( + "type" to "naddr", + "kind" to entity.kind, + "pubkey" to entity.author, + "identifier" to entity.dTag, + "relays" to entity.relay.map { it.url }, + ) + is NRelay -> mapOf("type" to "nrelay", "relays" to entity.relay) + is NEmbed -> mapOf("type" to "nembed", "event" to Output.mapper.readTree(entity.event.toJson())) + else -> return Output.error("bad_args", "unsupported entity type") + } + + Output.emit(result) + return 0 + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/EncodeCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/EncodeCommand.kt new file mode 100644 index 0000000000..fc5322586d --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/EncodeCommand.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NNote +import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import com.vitorpamplona.quartz.nip19Bech32.toNsec + +/** + * `amy encode …` — build a NIP-19 entity from raw parts (nak's + * `encode`). Local, no network, no account. + * + * encode npub + * encode nsec + * encode note + * encode nevent [--author HEX] [--kind N] [--relay URL[,URL…]] + * encode nprofile [--relay URL[,URL…]] + * encode naddr --kind N --pubkey HEX --identifier D [--relay URL[,URL…]] + * + * Thin assembly only: every encoder lives in quartz's NIP-19 entities; this + * file parses flags and calls them. + */ +object EncodeCommand { + fun run(rest: Array): Int { + if (rest.isEmpty()) return Output.error("bad_args", "encode …") + val type = rest[0] + val args = Args(rest.drop(1).toTypedArray()) + + return when (type) { + "npub" -> emit("npub", NPub.create(hex32(args.positional(0, "pubkey-hex")))) + "nsec" -> emit("nsec", hex32(args.positional(0, "private-key-hex")).hexToByteArray().toNsec()) + "note" -> emit("note", NNote.create(hex32(args.positional(0, "event-id-hex")))) + "nevent" -> + emit( + "nevent", + NEvent.create( + idHex = hex32(args.positional(0, "event-id-hex")), + author = args.flag("author"), + kind = args.flag("kind")?.toIntOrNull(), + relays = relays(args), + ), + ) + "nprofile" -> + emit( + "nprofile", + NProfile.create( + authorPubKeyHex = hex32(args.positional(0, "pubkey-hex")), + relays = relays(args), + ), + ) + "naddr" -> + emit( + "naddr", + NAddress.create( + kind = args.requireFlag("kind").toIntOrNull() ?: return Output.error("bad_args", "--kind must be an integer"), + pubKeyHex = hex32(args.requireFlag("pubkey")), + dTag = args.flag("identifier", "") ?: "", + relays = relays(args), + ), + ) + else -> Output.error("bad_args", "encode $type (expected npub|nsec|note|nevent|nprofile|naddr)") + } + } + + /** Validate a 64-char hex key/id; bare-hex only — bech32 inputs go through `decode` first. */ + private fun hex32(value: String): String { + val v = value.trim().lowercase() + require(v.length == 64 && v.all { it in "0123456789abcdef" }) { + "expected 64-char hex, got '$value'" + } + return v + } + + private fun relays(args: Args): List = + args + .flag("relay") + ?.split(',') + ?.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it.trim()) } + .orEmpty() + + private fun emit( + key: String, + value: String, + ): Int { + Output.emit(mapOf(key to value)) + return 0 + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyCommands.kt new file mode 100644 index 0000000000..40cd57936d --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyCommands.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Identity +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray + +/** + * `amy key …` — standalone key utilities (nak's `key`). Local, no network, + * no account; these never touch `~/.amy/` — they operate purely on the keys + * passed in. Use `amy init` / `amy login` to persist an identity. + * + * key generate mint a fresh keypair (prints nsec + npub + hex) + * key public derive the public key from a secret key + * + * Thin assembly only: key generation + bech32 derivation live in quartz + * (reused here via [Identity], which wraps Quartz's `KeyPair`). + */ +object KeyCommands { + fun dispatch(rest: Array): Int { + if (rest.isEmpty()) return Output.error("bad_args", "key ") + val tail = rest.drop(1).toTypedArray() + return when (rest[0]) { + "generate" -> generate() + "public" -> public(tail) + else -> Output.error("bad_args", "key ${rest[0]} (expected generate|public)") + } + } + + private fun generate(): Int { + val id = Identity.create() + Output.emit( + mapOf( + "nsec" to id.nsec, + "npub" to id.npub, + "private_key" to id.privKeyHex, + "pubkey" to id.pubKeyHex, + ), + ) + return 0 + } + + private fun public(rest: Array): Int { + val args = Args(rest) + val input = args.positional(0, "secret-key").trim() + val id = + try { + when { + input.startsWith("nsec") -> Identity.fromNsec(input) + input.length == 64 && input.lowercase().all { it in "0123456789abcdef" } -> + Identity.fromPrivateKey(input.hexToByteArray()) + else -> return Output.error("bad_args", "expected an nsec or a 64-char hex private key") + } + } catch (e: Exception) { + return Output.error("bad_args", "could not parse secret key: ${e.message}") + } + Output.emit(mapOf("npub" to id.npub, "pubkey" to id.pubKeyHex)) + return 0 + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/VerifyCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/VerifyCommand.kt new file mode 100644 index 0000000000..0733a00e71 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/VerifyCommand.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.verifyId +import com.vitorpamplona.quartz.nip01Core.crypto.verifySignature + +/** + * `amy verify [EVENT-JSON]` — check a Nostr event's id hash and signature + * (nak's `verify`). Local, no network, no account. The event JSON is read + * from the positional argument, or from stdin when the argument is omitted + * or `-`. + * + * Reports `id_ok` (computed id matches the `id` field) and `signature_ok` + * separately so a caller can tell a tampered id apart from a bad sig. + * `valid` is the conjunction. Exit code stays 0 — the result is data, not + * a runtime failure (parse errors are runtime/bad_args). + */ +object VerifyCommand { + fun run(rest: Array): Int { + val args = Args(rest) + val arg = args.positionalOrNull(0) + val json = + if (arg == null || arg == "-") { + System.`in` + .readBytes() + .decodeToString() + .trim() + } else { + arg.trim() + } + if (json.isEmpty()) return Output.error("bad_args", "no event JSON on the argument or stdin") + + val event = + try { + Event.fromJson(json) + } catch (e: Exception) { + return Output.error("bad_args", "could not parse event JSON: ${e.message}") + } + + val idOk = event.verifyId() + val sigOk = event.verifySignature() + + Output.emit( + mapOf( + "valid" to (idOk && sigOk), + "id" to event.id, + "id_ok" to idOk, + "signature_ok" to sigOk, + ), + ) + return 0 + } +}