Merge remote-tracking branch 'origin/main' into claude/graperank-wot-cli-qreg2a

# Conflicts:
#	cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StoreCommands.kt
This commit is contained in:
Claude
2026-07-07 23:18:50 +00:00
36 changed files with 732 additions and 70 deletions
+9
View File
@@ -38,6 +38,15 @@ What every caller — user, script, agent, CI — can rely on:
copy to move. Tests isolate by overriding `$HOME` for the amy
subprocess (`HOME=/tmp/run.123 amy --account alice …`) — same
convention `git`, `gpg`, and `npm` use.
- **An account is only required to _sign_.** Read-only verbs (relay
queries, the shared `store`, `offer`/`debit info`, and the stateless
primitives) run against an empty `~/.amy/` — `DataDir.resolveOptional`
hands them an accountless dir (its `hasAccount = false`) pointing only at
the shared event store, and `Context.openOrAnonymous` gives them an
ephemeral key-less identity (they read fine, they just can't
authenticate). Signing verbs go through `Context.open`, which re-asserts
the account requirement — `init`/`create`/`login`/`logoff`/`whoami`
resolve strictly, since they operate on the account dir itself.
Only the `--json` shape and the exit codes are public API. The default
text format is allowed to change between releases. The five design
+19 -5
View File
@@ -374,6 +374,8 @@ HTTP endpoint. Reuses quartz's `Nip86Client` and the shared `Nip86Retriever`
| `amy login KEY [--password X]` | Import an existing identity (`nsec`/`ncryptsec`/mnemonic/`npub`/`nprofile`/hex/NIP-05). |
| `amy whoami` | Print the active account's name + npub. |
| `amy use NAME` / `--clear` / no-arg | Pin / clear / inspect the active account. |
| `amy status` | Read-only overview of everything under `~/.amy/`: every account, which one is current, each signer type (local keychain/ncryptsec/plaintext, NIP-46 bunker, or read-only) and whether it can sign, the local Marmot / Cashu / alias / sync-cursor footprint per account, and the shared event store's size. Built for the returning user. No keychain prompt, no network. |
| `amy logoff [--yes] [--keep-events]` | Log off an account: delete its key + backend secret, the whole `~/.amy/<account>/` directory (run-state, aliases, cashu counters, Marmot state), the `current` pin if it points here, and the account's events (authored + `#p`-addressed) in the shared store. `--keep-events` leaves the shared cache alone. Destructive and irreversible — requires `--yes`; without it, prints a dry run and exits 2. |
### Social
@@ -588,7 +590,18 @@ matches that:
1. If `~/.amy/current` is set, use it.
2. Else if exactly one account exists, use it (silent auto-pick).
3. Else error and list the candidates so you can disambiguate.
3. Else — for a **read-only** verb, run **anonymously**; for a **signing**
verb, error and list the candidates so you can disambiguate.
**No account? Reads still work.** Verbs that only query relays or the shared
event store — `fetch`, `subscribe`, `count`, `publish` (broadcasts a
pre-signed event), `outbox`, `search`, `sync`, `store …`, the read halves of
`profile`/`notes`/`git`/`podcast`/`podcast20`, `nsite`/`napplet` fetch/serve/
list, `blossom download`/`check`, `offer`/`debit info`, and every stateless
primitive — run against an empty `~/.amy/` with a throwaway key. They read
fine; they just can't authenticate. Only verbs that **sign or encrypt with
your key** (post, edit, follow, dm, marmot, zap, relay-list edits, blossom
upload/list/delete, cashu, …) require an account — and say so.
`amy use NAME` writes `~/.amy/current`; `amy use --clear` removes it.
For one-off override, prepend `--account NAME` to any command.
@@ -654,11 +667,12 @@ Inside the amy process there's no test mode — it just sees a fresh
## Troubleshooting
- **`no account at ~/.amy`** — you haven't created one yet. Run
- **`no account configured` / `multiple accounts in ~/.amy (alice, bob)`** —
only **signing** verbs raise these; reads run anonymously instead (see
"No account? Reads still work" above). Create one with
`amy --account NAME init` (bare keypair) or `amy --account NAME create`
(full Amethyst-style bootstrap).
- **`multiple accounts in ~/.amy (alice, bob)`** — pin one with
`amy use NAME` or pass `--account NAME` per command.
(full Amethyst-style bootstrap), or pin/select one with `amy use NAME` /
`--account NAME`.
- **`current pins 'X' but ~/.amy/X doesn't exist`** — the active-account
marker is stale. Rewrite with `amy use OTHER` or `amy use --clear`.
- **`no_dm_relays`** — recipient hasn't published a kind:10050 inbox.
+2
View File
@@ -43,6 +43,8 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command ·
|---|---|---|
| Identity create / import (`nsec`, `ncryptsec`, mnemonic, `npub`, `nprofile`, hex, NIP-05) | ✅ | `LoginCommand` + Quartz NIP-05 / NIP-06 / NIP-49 |
| Account bootstrap (nine events) | ✅ | `commons/account/AccountBootstrapEvents.kt` |
| Account logoff (`amy logoff`) — delete key + per-account state + the account's events in the shared store | ✅ | `LogoffCommand`. `--yes`-gated; `--keep-events` skips the shared-cache purge. |
| Status overview (`amy status`) — every account, current pin, signer type + can-sign, per-account Marmot/Cashu/alias/cursor footprint, shared event-store size | ✅ | `StatusCommand`. Cross-account, read-only, metadata-only (no keychain prompt, no network). Store stats via shared `StoreStats`. |
| Relay config — every relay-list bucket (nip65 10002 via `outbox`/`inbox`/`nip65` nouns with spec read/write merge, dm 10050, key-package 10051, search 10007, private-outbox 10013, blocked 10006, trusted 10089, proxy 10087, indexer 10086, broadcast 10088, favorite 10012) — noun-first `relay <noun> add/remove/set/clear/list` + fan-out `relay add/remove` + publish | ✅ | `RelayCommands`. Mirrors the Android relay-settings screen. Local relays (device pref) + relay sets (30002) intentionally out of scope. |
| MLS KeyPackage publish + fetch | ✅ | `commons/marmot/MarmotManager` |
| Marmot group create / add / rename / promote / demote / remove / leave | ✅ | `commons/marmot/` |
@@ -143,6 +143,16 @@ data class Identity(
npub = pubHex.hexToByteArray().toNpub(),
)
/**
* Ephemeral, key-less identity for anonymous read-only runs (no
* account on disk). It mints a throwaway public key so the
* relay-list fallbacks (`outboxRelays()` etc.) resolve to the
* built-in defaults, and it carries no private key, so any attempt
* to sign/encrypt fails loudly — "you can read, you just can't
* auth". Used by [com.vitorpamplona.amethyst.cli.Context.openOrAnonymous].
*/
fun anonymous(): Identity = fromPublicKeyHex(KeyPair().pubKey.toHexKey())
/**
* Rebuild an in-memory identity after a load. Accepts the public
* parts that live on disk and a private key resolved from the
@@ -204,6 +214,17 @@ class DataDir(
val eventsDir: File,
val accountName: String,
val secrets: SecretStore,
/**
* Whether this points at a concrete account. `false` for the
* accountless directory [resolveOptional] hands back when `~/.amy/`
* has no unambiguous account — [root] then points at the shared
* sibling and only [eventsDir] (the cross-account event store) is
* meaningful. Read-only verbs run anonymously against it; signing
* verbs get [noAccountDetail] via `Context.open`.
*/
val hasAccount: Boolean = true,
/** Human-readable reason there is no account, for the signing-verb error. */
val noAccountDetail: String? = null,
) {
val identityFile = File(root, "identity.json")
val stateFile = File(root, "state.json")
@@ -231,12 +252,16 @@ class DataDir(
init {
SecureFileIO.secureMkdirs(root)
SecureFileIO.secureMkdirs(groupsDir)
// Tighten perms on any data already on disk from an older, unhardened CLI.
SecureFileIO.tighten(identityFile)
SecureFileIO.tighten(stateFile)
SecureFileIO.tighten(marmotDir)
SecureFileIO.tighten(keyPackageBundleFile)
// The accountless dir only ever exposes the shared event store; don't
// seed per-account marmot dirs / tighten identity files under it.
if (hasAccount) {
SecureFileIO.secureMkdirs(groupsDir)
// Tighten perms on any data already on disk from an older, unhardened CLI.
SecureFileIO.tighten(identityFile)
SecureFileIO.tighten(stateFile)
SecureFileIO.tighten(marmotDir)
SecureFileIO.tighten(keyPackageBundleFile)
}
}
/**
@@ -391,6 +416,69 @@ class DataDir(
)
}
/**
* Like [resolve], but never throws when there is no account: read-only
* verbs can run without one. When `--account` is given it is honoured;
* otherwise the pin / sole-account are used if unambiguous. Failing
* that, returns an *accountless* [DataDir] (`hasAccount = false`) whose
* [root] is the shared sibling and whose [eventsDir] is still the
* cross-account event store — enough for anonymous relay queries and
* `store` maintenance. The reason no account was chosen is carried in
* [DataDir.noAccountDetail] so a signing verb can surface it.
*/
fun resolveOptional(
accountFlag: String?,
secrets: SecretStore,
): DataDir {
val rootBase = DEFAULT_ROOT
val sharedEvents = File(rootBase, "$SHARED_DIR_NAME/events-store").absoluteFile
if (accountFlag != null) {
val name = validateName(accountFlag)
return DataDir(File(rootBase, name).absoluteFile, sharedEvents, name, secrets)
}
val picked = pickAccountOptional(rootBase)
return if (picked.name != null) {
DataDir(File(rootBase, picked.name).absoluteFile, sharedEvents, picked.name, secrets)
} else {
DataDir(
root = File(rootBase, SHARED_DIR_NAME).absoluteFile,
eventsDir = sharedEvents,
accountName = SHARED_DIR_NAME,
secrets = secrets,
hasAccount = false,
noAccountDetail = picked.detail,
)
}
}
/** Result of [pickAccountOptional]: an account [name], or null plus a [detail] reason. */
private data class OptionalPick(
val name: String?,
val detail: String?,
)
/** Non-throwing sibling of [pickAccount]: null [name] with a [detail] when 0 / ambiguous. */
private fun pickAccountOptional(rootBase: File): OptionalPick {
val current = File(rootBase, CURRENT_MARKER_NAME)
if (current.isFile) {
val pinned = current.readText().trim()
if (pinned.isNotEmpty() && File(rootBase, pinned).isDirectory) {
return OptionalPick(pinned, null)
}
}
val accounts = listAccounts(rootBase)
return when (accounts.size) {
0 -> OptionalPick(null, "no account configured (create one with `amy --account <name> init`)")
1 -> OptionalPick(accounts.single(), null)
else ->
OptionalPick(
null,
"multiple accounts in ${rootBase.absolutePath} (${accounts.joinToString(", ")}); " +
"pick one with --account <name> or `amy use <name>`",
)
}
}
/**
* Auto-select an account when `--name` was not given. Honours
* `<root>/current` first (explicit pin from `amy use`), then
@@ -120,6 +120,14 @@ class Context(
val dataDir: DataDir,
val identity: Identity,
val state: RunState,
/**
* Anonymous read-only run: no account on disk, [identity] is an ephemeral
* key-less identity (see [Identity.anonymous]). Marmot state is not
* restored and run-state is not persisted — the run only reads relays and
* the shared event store. Signing verbs never take this path; they go
* through [Companion.open], which requires a real account.
*/
val anonymous: Boolean = false,
) : AutoCloseable {
private val okhttp =
OkHttpClient
@@ -225,9 +233,12 @@ class Context(
.OkHttpNip05Fetcher { _ -> okhttp },
)
private val mlsStore = FileMlsGroupStateStore(dataDir.groupsDir)
private val keyPackageStore = FileKeyPackageBundleStore(dataDir.keyPackageBundleFile)
private val messageStore = FileMarmotMessageStore(dataDir.groupsDir)
// Lazy so an anonymous read (no account dir) never materialises the
// per-account marmot stores — constructing them would `mkdir` group dirs
// under the shared root. Real accounts build them on first marmot use.
private val mlsStore by lazy { FileMlsGroupStateStore(dataDir.groupsDir) }
private val keyPackageStore by lazy { FileKeyPackageBundleStore(dataDir.keyPackageBundleFile) }
private val messageStore by lazy { FileMarmotMessageStore(dataDir.groupsDir) }
/**
* Shared Nostr event store for this run, opened via [StoreFactory]
@@ -240,7 +251,7 @@ class Context(
val store: IEventStore by storeDelegate
/** Fully-wired manager. Call [prepare] once before use to load persisted state. */
val marmot: MarmotManager = MarmotManager(signer, mlsStore, messageStore, keyPackageStore)
val marmot: MarmotManager by lazy { MarmotManager(signer, mlsStore, messageStore, keyPackageStore) }
// ------------------------------------------------------------------
// Cashu (NIP-60 / NIP-61) — shared wallet code from commons
@@ -359,7 +370,9 @@ class Context(
*/
suspend fun prepare() {
if (prepared) return
marmot.restoreAll()
// Anonymous runs have no account and therefore no marmot state to
// restore (and touching `marmot` would allocate the per-account stores).
if (!anonymous) marmot.restoreAll()
client.connect()
// A bunker account must open its NIP-46 response subscription and run
// the connect handshake before any signing/encryption call.
@@ -949,7 +962,8 @@ class Context(
}
override fun close() {
dataDir.saveRunState(state)
// Nothing to persist for an anonymous run (no account dir to write into).
if (!anonymous) dataDir.saveRunState(state)
(signer as? NostrSignerRemote)?.let {
try {
it.closeSubscription()
@@ -978,12 +992,21 @@ class Context(
*/
private const val GIFT_WRAP_LOOKBACK_SECS: Long = 2L * 24 * 60 * 60
/** Build a Context but require an identity to already exist — most commands can't run without one. */
/**
* Build a Context but require an account with a usable identity —
* signing verbs can't run without one. Throws [IllegalArgumentException]
* (→ exit 2) when no account was resolvable, carrying the "which
* account?" hint from [DataDir.resolveOptional]; throws
* [IllegalStateException] when the account exists but has no identity.
*/
fun open(dataDir: DataDir): Context {
require(dataDir.hasAccount) {
dataDir.noAccountDetail ?: "no account selected; pass --account <name> or run `amy use <name>`"
}
val identity =
dataDir.loadIdentityOrNull()
?: run {
System.err.println("No identity found at ${dataDir.identityFile}. Run `amethyst-cli init` first.")
System.err.println("No identity found at ${dataDir.identityFile}. Run `amy --account ${dataDir.accountName} init` first.")
throw IllegalStateException("no identity")
}
return Context(
@@ -992,5 +1015,24 @@ class Context(
state = dataDir.loadRunState(),
)
}
/**
* Context for read-only verbs: use the resolved account when one is
* present, otherwise run anonymously (ephemeral key-less identity, no
* persisted state). Lets `fetch`/`subscribe`/`count`/`publish`/`outbox`/
* … query relays and the shared store with no account on disk — they
* read fine, they just can't sign.
*/
fun openOrAnonymous(dataDir: DataDir): Context =
if (dataDir.hasAccount && dataDir.identityExists()) {
open(dataDir)
} else {
Context(
dataDir = dataDir,
identity = Identity.anonymous(),
state = RunState(),
anonymous = true,
)
}
}
}
@@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.cli.commands.KeyCommands
import com.vitorpamplona.amethyst.cli.commands.KeyPackageCommands
import com.vitorpamplona.amethyst.cli.commands.KindCommand
import com.vitorpamplona.amethyst.cli.commands.LoginCommand
import com.vitorpamplona.amethyst.cli.commands.LogoffCommand
import com.vitorpamplona.amethyst.cli.commands.MarmotResetCommand
import com.vitorpamplona.amethyst.cli.commands.MessageCommands
import com.vitorpamplona.amethyst.cli.commands.NamecoinCommand
@@ -61,6 +62,7 @@ import com.vitorpamplona.amethyst.cli.commands.PublishCommand
import com.vitorpamplona.amethyst.cli.commands.RelayCommands
import com.vitorpamplona.amethyst.cli.commands.SearchCommand
import com.vitorpamplona.amethyst.cli.commands.ServeCommand
import com.vitorpamplona.amethyst.cli.commands.StatusCommand
import com.vitorpamplona.amethyst.cli.commands.StoreCommands
import com.vitorpamplona.amethyst.cli.commands.SubscribeCommand
import com.vitorpamplona.amethyst.cli.commands.SyncCommand
@@ -137,6 +139,16 @@ class AwaitTimeout(
message: String,
) : RuntimeException(message)
/**
* Verbs that create, select, or delete the account/identity on disk. They
* write to (or read) the per-account directory directly rather than through
* `Context.open`, so they need a concrete account and must resolve strictly —
* an accountless run has nowhere to put a new identity. Every other verb
* resolves via [DataDir.resolveOptional] and either runs anonymously (reads)
* or re-asserts the requirement inside `Context.open` (signing).
*/
private val STRICT_ACCOUNT_VERBS = setOf("init", "create", "login", "logoff", "whoami")
private suspend fun dispatch(argv: Array<String>): Int {
if (argv.isEmpty() || argv[0] == "--help" || argv[0] == "-h") {
printUsage()
@@ -179,6 +191,14 @@ private suspend fun dispatch(argv: Array<String>): Int {
return UseCommand.run(tail)
}
// `status` is a cross-account, read-only overview of everything on
// disk under ~/.amy/. Like `use`, it must work regardless of how many
// accounts exist (zero, one, or many), so it dispatches before account
// resolution rather than through the single-account DataDir path.
if (head == "status") {
return StatusCommand.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.
@@ -206,13 +226,33 @@ private suspend fun dispatch(argv: Array<String>): Int {
return CashuMintCommands.dispatch(tail.drop(1).toTypedArray())
}
// `offer info NOFFER` / `debit info NDEBIT` decode a CLINK pointer locally —
// no network, no account. The rest of `offer`/`debit` operates on the account.
if (head == "offer" && tail.firstOrNull() == "info") {
return OfferCommands.info(tail.drop(1).toTypedArray())
}
if (head == "debit" && tail.firstOrNull() == "info") {
return DebitCommands.info(tail.drop(1).toTypedArray())
}
val secrets = SecretStore.from(backendFlag = secretBackendFlag, passphraseFile = passphraseFileFlag)
val dataDir = DataDir.resolve(accountFlag = accountFlag, secrets = secrets)
// Identity-lifecycle verbs create / select / delete the account itself, so
// they need a concrete account and resolve strictly (helpful ambiguity
// errors). Everything else resolves optionally: read-only verbs then run
// anonymously when there is no account, while signing verbs re-assert the
// requirement through `Context.open`.
val dataDir =
if (head in STRICT_ACCOUNT_VERBS) {
DataDir.resolve(accountFlag = accountFlag, secrets = secrets)
} else {
DataDir.resolveOptional(accountFlag = accountFlag, secrets = secrets)
}
return when (head) {
"init" -> InitCommands.init(dataDir, Args(tail))
"create" -> CreateCommand.run(dataDir, tail)
"login" -> LoginCommand.run(dataDir, tail)
"logoff" -> LogoffCommand.run(dataDir, tail)
"whoami" -> InitCommands.whoami(dataDir)
"relay" -> RelayCommands.dispatch(dataDir, tail)
"marmot" -> marmotDispatch(dataDir, tail)
@@ -341,7 +381,12 @@ private fun printUsage() {
| 1. --account X if given.
| 2. ~/.amy/current marker (set by `amy use X`).
| 3. Sole subdirectory of ~/.amy/ other than shared/.
| 4. Error — disambiguate with --account or `amy use`.
| 4. Read-only verbs (fetch, subscribe, count, publish, outbox,
| search, sync, store, profile/git/podcast reads, nsite/napplet
| fetch, decode/encode/… primitives, offer/debit info) run
| ANONYMOUSLY — they query relays and the shared store with no
| account, they just can't sign. Signing verbs error here:
| disambiguate with --account or `amy use`.
|
| Test harnesses isolate by overriding ${'$'}HOME for the amy
| subprocess (`HOME=/tmp/run.123 amy --account alice ...`).
@@ -349,6 +394,9 @@ private fun printUsage() {
| use NAME pin NAME as the active account
| use --clear remove the pin
| use print current pin + available accounts
| status read-only overview of every account, signer
| type, local Marmot/Cashu state, and the shared
| event store (no keychain prompt, no network)
|
|Output:
| Default: human-readable text on stdout.
@@ -395,6 +443,9 @@ private fun printUsage() {
| create [--name NAME] provision a full Amethyst-style account + publish bootstrap events
| login KEY [--password X] import (nsec|ncryptsec|mnemonic|npub|nprofile|hex|nip05|bunker://)
| whoami print current identity
| logoff [--yes] [--keep-events] log off: delete this account's key, per-account state,
| and its events in the shared store (--keep-events skips the
| cache purge). Requires --yes; without it, prints a dry run.
|
|Remote signing (NIP-46):
| bunker [--relay URL[,URL…]] run a remote signer for this (local-key) account; prints a
@@ -0,0 +1,121 @@
/*
* 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
import java.io.IOException
import java.nio.file.Files
import java.nio.file.Path
import java.util.concurrent.TimeUnit
import kotlin.io.path.exists
/**
* Read-only introspection of a file-backed Nostr event store on disk.
*
* Pure filesystem walk — no relay traffic, no writer lock, no [Context].
* Shared by `amy store stat` (full detail) and `amy status` (a compact
* roll-up alongside the account overview).
*/
data class StoreStats(
val events: Long,
/** Per-kind event counts derived from `idx/kind/<k>/`, sorted by kind string. */
val byKind: Map<String, Long>,
val diskBytes: Long,
/** Oldest / newest event file mtime, in unix seconds. Null on an empty store. */
val oldestAt: Long?,
val newestAt: Long?,
val root: Path,
) {
val distinctKinds: Int get() = byKind.size
companion object {
/** Compute stats for the store rooted at [storeRoot]. Missing dir → all-zero. */
fun of(storeRoot: Path): StoreStats {
if (!storeRoot.exists()) {
return StoreStats(0, emptyMap(), 0L, null, null, storeRoot.toAbsolutePath())
}
val eventsRoot = storeRoot.resolve("events")
var count = 0L
var oldest: Long? = null
var newest: Long? = null
if (Files.isDirectory(eventsRoot)) {
Files.walk(eventsRoot).use { stream ->
for (p in stream) {
if (!Files.isRegularFile(p)) continue
if (!p.fileName.toString().endsWith(".json")) continue
count++
val mt =
try {
Files.getLastModifiedTime(p).to(TimeUnit.SECONDS)
} catch (_: IOException) {
continue
}
val o = oldest
if (o == null || mt < o) oldest = mt
val n = newest
if (n == null || mt > n) newest = mt
}
}
}
// Histogram from idx/kind/<k>/ — for a healthy store this is
// exactly one entry per (kind, event), so summing matches `count`.
// Mismatch points at index drift; run `amy store scrub` to fix.
val kindRoot = storeRoot.resolve("idx/kind")
val byKind = sortedMapOf<String, Long>()
if (Files.isDirectory(kindRoot)) {
Files.list(kindRoot).use { stream ->
for (kindDir in stream) {
if (!Files.isDirectory(kindDir)) continue
val n = Files.list(kindDir).use { it.count() }
byKind[kindDir.fileName.toString()] = n
}
}
}
return StoreStats(
events = count,
byKind = byKind,
diskBytes = walkSize(storeRoot),
oldestAt = oldest,
newestAt = newest,
root = storeRoot.toAbsolutePath(),
)
}
private fun walkSize(root: Path): Long {
if (!Files.exists(root)) return 0L
var total = 0L
Files.walk(root).use { stream ->
for (p in stream) {
if (!Files.isRegularFile(p)) continue
total +=
try {
Files.size(p)
} catch (_: IOException) {
0L
}
}
}
return total
}
}
}
@@ -85,7 +85,8 @@ object BlossomCommands {
.map { it.trim() }
.filter { it.isNotEmpty() }
Context.open(dataDir).use { _ ->
// Read-only HEAD probe — no auth, so it runs anonymously without an account.
Context.openOrAnonymous(dataDir).use { _ ->
val http = OkHttpClient()
val results =
hashes.map { hash ->
@@ -188,7 +189,8 @@ object BlossomCommands {
val server = args.flag("server")
val url = if (server != null && !target.startsWith("http")) BlossomServerUrl.blob(server, target) else target
Context.open(dataDir).use { ctx ->
// Public download — no auth, so it runs anonymously without an account.
Context.openOrAnonymous(dataDir).use { ctx ->
val bytes =
BlossomClient().download(url)
?: return Output.error("not_found", "server returned no blob for $url")
@@ -47,7 +47,7 @@ object CountCommand {
val timeoutMs = (args.flag("timeout")?.toLongOrNull() ?: 15L) * 1000
val filter = RawEventSupport.buildFilter(args)
Context.open(dataDir).use { ctx ->
Context.openOrAnonymous(dataDir).use { ctx ->
ctx.prepare()
val relays = RawEventSupport.queryTargets(ctx, args)
if (relays.isEmpty()) return Output.error("no_relays", "no relays available; pass --relay or run `amy relay add`")
@@ -60,7 +60,7 @@ object DebitCommands {
)
/** Local decode of an `ndebit` pointer — no network, no account needed. */
private fun info(rest: Array<String>): Int {
internal fun info(rest: Array<String>): Int {
val args = Args(rest)
val debit =
ClinkPointerParser.parse(args.positional(0, "ndebit").trim()) as? NDebit
@@ -61,7 +61,10 @@ object FeedCommand {
val until = args.flag("until")?.toLongOrNull()
val timeoutSecs = args.longFlag("timeout", 8L)
Context.open(dataDir).use { ctx ->
// Read-only: runs anonymously when there is no account. `--author` /
// `--following` still work; the bare "self" feed just has no self to
// resolve without an account.
Context.openOrAnonymous(dataDir).use { ctx ->
ctx.prepare()
val (authors, mode) =
@@ -94,7 +94,7 @@ object FetchCommand {
val filter = RawEventSupport.buildFilter(args).copy(limit = effectiveLimit)
val paginate = args.bool("paginate") || args.bool("all")
Context.open(dataDir).use { ctx ->
Context.openOrAnonymous(dataDir).use { ctx ->
ctx.prepare()
val relays = RawEventSupport.queryTargets(ctx, args)
if (relays.isEmpty()) return Output.error("no_relays", "no relays available; pass --relay or run `amy relay add`")
@@ -148,7 +148,7 @@ object FetchCommand {
timeoutMs: Long,
): Int {
val code = codeArg.removePrefix("nostr:")
Context.open(dataDir).use { ctx ->
Context.openOrAnonymous(dataDir).use { ctx ->
ctx.prepare()
var filter: Filter
@@ -113,7 +113,9 @@ object GitCommands {
rest: Array<String>,
): Int {
val args = Args(rest)
Context.open(dataDir).use { ctx ->
// Read-only: runs anonymously when there is no account (defaults to
// the anonymous key, so pass a USER to list someone's repos).
Context.openOrAnonymous(dataDir).use { ctx ->
ctx.prepare()
val author = args.positionalOrNull(0)?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex
val relays = RawEventSupport.queryTargets(ctx, args)
@@ -143,7 +145,7 @@ object GitCommands {
return Output.error("bad_args", "not a git repository address (expected kind ${GitRepositoryEvent.KIND}, got ${addr.kind})")
}
Context.open(dataDir).use { ctx ->
Context.openOrAnonymous(dataDir).use { ctx ->
ctx.prepare()
val repo = fetchRepo(ctx, addr, args) ?: return Output.error("not_found", "no repository announcement found for $coord")
Output.emit(repoSummary(repo) + mapOf("event_id" to repo.id, "content" to repo.content))
@@ -77,7 +77,7 @@ object KeyCommands {
Output.emit(mapOf("valid" to false))
return 0
}
val npub = hex!!.hexToByteArray().toNpub()
val npub = hex.hexToByteArray().toNpub()
Output.emit(mapOf("valid" to true, "pubkey" to hex, "npub" to npub))
return 0
}
@@ -0,0 +1,167 @@
/*
* 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.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.store.fs.FsEventStore
import java.io.File
/**
* `amy logoff [--yes] [--keep-events]` — log off an account and clear its
* local data.
*
* "Logging off" a CLI with no server session means removing everything the
* account left on this machine:
* - the identity file and any backend-held secret (keychain / ncryptsec /
* plaintext) — via [DataDir.deleteIdentity],
* - the rest of the per-account directory `~/.amy/<account>/` (run-state
* cursors, aliases, cashu counters, all MLS/Marmot state),
* - the active-account pin at `~/.amy/current`, if it points here,
* - and the account's events in the SHARED store at
* `~/.amy/shared/events-store/`.
*
* The event store is shared across every account on the machine, so this
* does NOT wipe it wholesale — it deletes only the events that involve this
* account: those it authored (`authors`) plus those addressed to it via a
* `#p` tag (inbound gift wraps, nutzaps, reactions, mentions…). Other
* accounts' cached events are untouched. Pass `--keep-events` to leave the
* shared cache alone and only remove the identity + per-account state.
*
* The account is selected the normal way (the `--account` flag, the
* `current` pin, or the sole account) — when more than one account exists
* and none is pinned, [DataDir.resolve] already errors out asking the caller
* to disambiguate, so logoff never guesses which account to destroy.
*
* Reads the public key straight from `identity.json` (never unlocking the
* private key), so it needs no passphrase and pops no keychain prompt.
*
* Requires `--yes` to execute, because it is destructive and cannot be
* undone — the private key is gone with the identity file. Without `--yes`
* the command reports what it would delete and exits with code 2.
*/
object LogoffCommand {
suspend fun run(
dataDir: DataDir,
tail: Array<String>,
): Int {
val confirmed = tail.any { it == "--yes" || it == "-y" }
val keepEvents = tail.any { it == "--keep-events" }
// Read the on-disk identity metadata only — no SecretStore round-trip,
// so we never prompt for a passphrase or trip a keychain dialog just
// to log off.
val idFile =
dataDir.loadIdentityFileOrNull()
?: return Output.error(
"no_account",
"no identity at ${dataDir.identityFile.absolutePath}; nothing to log off",
)
val pubkey = idFile.pubKeyHex
val marker = File(DataDir.DEFAULT_ROOT, DataDir.CURRENT_MARKER_NAME)
val isPinned = marker.isFile && marker.readText().trim() == dataDir.accountName
// Everything the account touched in the shared store: authored by it,
// or addressed to it via a #p tag (gift wraps, nutzaps, reactions…).
val involvedFilters =
listOf(
Filter(authors = listOf(pubkey)),
Filter(tags = mapOf("p" to listOf(pubkey))),
)
if (!confirmed) {
val eventCount = if (keepEvents) 0 else withStore(dataDir) { it.count(involvedFilters) }
Output.emit(
mapOf(
"dry_run" to true,
"account" to dataDir.accountName,
"npub" to idFile.npub,
"pubkey" to pubkey,
"account_dir" to dataDir.root.absolutePath,
"pinned" to isPinned,
"events_to_purge" to eventCount,
"keep_events" to keepEvents,
"detail" to "pass --yes to permanently delete this account's key, local state" +
(if (keepEvents) "" else ", and cached events"),
),
)
return 2
}
// 1. Purge the account's events from the shared store.
var purged = 0
if (!keepEvents) {
withStore(dataDir) { store ->
val before = store.count(involvedFilters)
store.delete(involvedFilters)
purged = (before - store.count(involvedFilters)).coerceAtLeast(0)
}
}
// 2. Remove the identity file and any backend-held secret.
dataDir.deleteIdentity()
// 3. Wipe the rest of the per-account directory (run-state, aliases,
// cashu counters, Marmot/MLS state). The shared events-store lives
// outside this directory, so it is not affected.
val dirFullyRemoved = dataDir.root.deleteRecursively()
// 4. Drop the active-account pin if it pointed at this account.
val clearedPin = isPinned && marker.delete()
Output.emit(
mapOf(
"logoff" to true,
"account" to dataDir.accountName,
"npub" to idFile.npub,
"events_purged" to purged,
"removed_dir" to dataDir.root.absolutePath,
"dir_fully_removed" to dirFullyRemoved,
"cleared_pin" to clearedPin,
),
)
return 0
}
/**
* Open the shared [FsEventStore] directly — logoff needs the store but no
* identity, signer, or relays, so it skips [com.vitorpamplona.amethyst.cli.Context.open]
* (which requires a bootstrapped identity). Mirrors `StoreCommands.withStore`.
*/
private inline fun <T> withStore(
dataDir: DataDir,
body: (FsEventStore) -> T,
): T {
val store =
FsEventStore(
root = dataDir.eventsDir.toPath(),
eventToJson = JacksonMapper::toJsonPretty,
)
try {
return body(store)
} finally {
store.close()
}
}
}
@@ -79,7 +79,7 @@ object NappletCommands {
val extraRelays = StaticSiteFetch.commaList(args.flag("relay"))
val timeoutSecs = args.longFlag("timeout", 8L)
Context.open(dataDir).use { ctx ->
Context.openOrAnonymous(dataDir).use { ctx ->
ctx.prepare()
val authorHex = ctx.requireUserHex(author)
val relays =
@@ -134,7 +134,7 @@ object NappletCommands {
val extraRelays = StaticSiteFetch.commaList(args.flag("relay"))
val timeoutSecs = args.longFlag("timeout", 8L)
Context.open(dataDir).use { ctx ->
Context.openOrAnonymous(dataDir).use { ctx ->
ctx.prepare()
val authorHex = ctx.requireUserHex(author)
val relays =
@@ -193,7 +193,7 @@ object NappletCommands {
val extraServers = StaticSiteFetch.commaList(args.flag("server"))
val extraRelays = StaticSiteFetch.commaList(args.flag("relay"))
Context.open(dataDir).use { ctx ->
Context.openOrAnonymous(dataDir).use { ctx ->
ctx.prepare()
val relays =
extraRelays
@@ -81,7 +81,7 @@ object NostrConnect {
}
}
if (secret == null) return null
return Offer(clientPubkey, relays, secret!!, name)
return Offer(clientPubkey, relays, secret, name)
}
private fun buildOffer(
@@ -79,7 +79,7 @@ object NsiteCommands {
val extraRelays = StaticSiteFetch.commaList(args.flag("relay"))
val timeoutSecs = args.longFlag("timeout", 8L)
Context.open(dataDir).use { ctx ->
Context.openOrAnonymous(dataDir).use { ctx ->
ctx.prepare()
val authorHex = ctx.requireUserHex(author)
val relays =
@@ -145,7 +145,7 @@ object NsiteCommands {
val extraRelays = StaticSiteFetch.commaList(args.flag("relay"))
val timeoutSecs = args.longFlag("timeout", 8L)
Context.open(dataDir).use { ctx ->
Context.openOrAnonymous(dataDir).use { ctx ->
ctx.prepare()
val authorHex = ctx.requireUserHex(author)
val relays =
@@ -203,7 +203,7 @@ object NsiteCommands {
val extraServers = StaticSiteFetch.commaList(args.flag("server"))
val extraRelays = StaticSiteFetch.commaList(args.flag("relay"))
Context.open(dataDir).use { ctx ->
Context.openOrAnonymous(dataDir).use { ctx ->
ctx.prepare()
val authorHex = ctx.requireUserHex(author)
@@ -109,7 +109,7 @@ object OfferCommands {
}
/** Local decode of a `noffer` pointer — no network, no account needed. */
private fun info(rest: Array<String>): Int {
internal fun info(rest: Array<String>): Int {
val args = Args(rest)
val offer =
ClinkPointerParser.parse(args.positional(0, "noffer").trim()) as? NOffer
@@ -44,7 +44,7 @@ object OutboxCommand {
val refresh = args.bool("refresh")
val timeoutMs = (args.flag("timeout")?.toLongOrNull() ?: 8L) * 1000
Context.open(dataDir).use { ctx ->
Context.openOrAnonymous(dataDir).use { ctx ->
ctx.prepare()
val pubkey = ctx.requireUserHex(user)
@@ -219,7 +219,9 @@ object Podcast20Commands {
): Int {
val args = Args(rest)
val limit = args.intFlag("limit", 50)
Context.open(dataDir).use { ctx ->
// Read-only: runs anonymously when there is no account (pass a USER to
// list someone else's episodes).
Context.openOrAnonymous(dataDir).use { ctx ->
ctx.prepare()
val author = args.positionalOrNull(0)?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex
val relays = RawEventSupport.queryTargets(ctx, args)
@@ -136,7 +136,9 @@ object PodcastCommands {
): Int {
val args = Args(rest)
val limit = args.intFlag("limit", 50)
Context.open(dataDir).use { ctx ->
// Read-only: runs anonymously when there is no account (pass a USER to
// list someone else's podcasts).
Context.openOrAnonymous(dataDir).use { ctx ->
ctx.prepare()
val author = args.positionalOrNull(0)?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex
val relays = RawEventSupport.queryTargets(ctx, args)
@@ -63,7 +63,9 @@ object ProfileCommands {
val args = Args(rest)
val refresh = args.bool("refresh")
val timeoutSecs = args.longFlag("timeout", 8L)
Context.open(dataDir).use { ctx ->
// Read-only: runs anonymously when there is no account (an explicit
// USER is then required, since there is no "own" profile to default to).
Context.openOrAnonymous(dataDir).use { ctx ->
ctx.prepare()
val pubKey =
args.positionalOrNull(0)?.let { ctx.requireUserHex(it) }
@@ -55,7 +55,7 @@ object PublishCommand {
return Output.error("invalid_event", "event id/signature does not verify — refusing to publish")
}
Context.open(dataDir).use { ctx ->
Context.openOrAnonymous(dataDir).use { ctx ->
ctx.prepare()
val targets = RawEventSupport.publishTargets(ctx, args)
if (targets.isEmpty()) {
@@ -145,7 +145,7 @@ object SearchCommand {
timeoutMs: Long,
render: (List<Event>) -> List<Map<String, Any?>>,
): Int {
Context.open(dataDir).use { ctx ->
Context.openOrAnonymous(dataDir).use { ctx ->
ctx.prepare()
val relays =
SearchActions.resolveSearchRelays(
@@ -0,0 +1,167 @@
/*
* 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.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.amethyst.cli.RunState
import com.vitorpamplona.amethyst.cli.StoreStats
import com.vitorpamplona.amethyst.cli.secrets.IdentityFile
import com.vitorpamplona.amethyst.cli.secrets.IdentitySecret
import java.io.File
/**
* `amy status` — a single at-a-glance overview of everything amy is
* holding on disk under `~/.amy/`. Built for the returning user: "I
* haven't run this in months — what accounts do I have, which one is
* active, can they still sign, and how big is the local database?"
*
* Cross-account by design, so it dispatches *before* account resolution
* (like `use`) and never fails on "zero accounts" or "ambiguous account".
* It is strictly read-only and metadata-only: it parses the on-disk
* `identity.json` / `state.json` / `aliases.json` and walks the shared
* event store, but it never unlocks a private key (no keychain prompt,
* no NIP-49 passphrase) and never touches the network.
*
* Per account it reports the npub, how the key is stored (local keychain
* / ncryptsec / plaintext, a NIP-46 bunker, or read-only), whether it can
* sign, and the local footprint that account has accumulated: aliases,
* Marmot groups, a published KeyPackage bundle, a Cashu wallet, and the
* sync cursors that tell catch-up commands where they left off.
*/
object StatusCommand {
fun run(tail: Array<String>): Int {
// `status` takes no positional args; tolerate an accidental one
// rather than erroring — it's a read-only inspection command.
val rootBase = DataDir.DEFAULT_ROOT
val currentPin =
File(rootBase, DataDir.CURRENT_MARKER_NAME)
.takeIf { it.isFile }
?.readText()
?.trim()
?.ifEmpty { null }
val accountNames = DataDir.listAccounts(rootBase)
val accounts = accountNames.map { accountRow(File(rootBase, it), it, it == currentPin) }
// The event store is shared across every account.
val store = StoreStats.of(File(rootBase, "shared/events-store").toPath())
Output.emit(
mapOf(
"root" to rootBase.absolutePath,
"current" to currentPin,
"account_count" to accounts.size,
"accounts" to accounts,
"store" to
mapOf(
"events" to store.events,
"distinct_kinds" to store.distinctKinds,
"disk_bytes" to store.diskBytes,
"oldest_at" to store.oldestAt,
"newest_at" to store.newestAt,
"root" to store.root.toString(),
),
),
)
return 0
}
private fun accountRow(
accountRoot: File,
name: String,
isCurrent: Boolean,
): Map<String, Any?> {
val identity = readIdentity(File(accountRoot, "identity.json"))
val signer = classifySigner(identity)
val marmotGroups =
File(accountRoot, "marmot/groups")
.listFiles { f -> f.name.endsWith(".state") }
?.size ?: 0
val hasKeyPackage = File(accountRoot, "marmot/keypackages.bundle").isFile
val hasCashuWallet = File(accountRoot, "cashu.json").isFile
val aliasCount = readAliases(File(accountRoot, "aliases.json")).size
val runState = readRunState(File(accountRoot, "state.json"))
// LinkedHashMap so the text renderer prints fields in this order.
val row = LinkedHashMap<String, Any?>()
row["name"] = name
row["current"] = isCurrent
row["npub"] = identity?.npub
row["hex"] = identity?.pubKeyHex
row["signer"] = signer.kind
row["key_storage"] = signer.storage
row["can_sign"] = signer.canSign
if (signer.bunkerRelays != null) row["bunker_relays"] = signer.bunkerRelays
row["aliases"] = aliasCount
row["marmot_groups"] = marmotGroups
row["key_package_published"] = hasKeyPackage
row["cashu_wallet"] = hasCashuWallet
row["dm_cursor_at"] = runState.giftWrapSince
row["marmot_group_cursors"] = runState.groupSince.size
return row
}
/**
* How this account can sign, derived purely from the on-disk
* [IdentityFile] — never resolves the secret itself.
* - `local` — an on-device private key ([storage] says where).
* - `bunker` — a NIP-46 remote signer ([bunkerRelays] lists it).
* - `read-only` — imported from an npub/nprofile/NIP-05; cannot sign.
*/
private data class SignerInfo(
val kind: String,
val storage: String?,
val canSign: Boolean,
val bunkerRelays: List<String>?,
)
private fun classifySigner(identity: IdentityFile?): SignerInfo {
if (identity == null) return SignerInfo("unknown", null, false, null)
identity.bunker?.let { bunker ->
return SignerInfo("bunker", secretStorageLabel(identity.secret), true, bunker.relays)
}
val storage = secretStorageLabel(identity.secret)
return when {
identity.secret != null -> SignerInfo("local", storage, true, null)
// Pre-secret-store data-dirs kept the key inline; still signable.
identity.privKeyHex != null || identity.nsec != null -> SignerInfo("local", "legacy-plaintext", true, null)
else -> SignerInfo("read-only", null, false, null)
}
}
private fun secretStorageLabel(secret: IdentitySecret?): String? =
when (secret) {
is IdentitySecret.Keychain -> "keychain:${secret.backend}"
is IdentitySecret.Ncryptsec -> "ncryptsec"
is IdentitySecret.Plaintext -> "plaintext"
null -> null
}
private fun readIdentity(file: File): IdentityFile? = if (file.isFile) runCatching { Output.mapper.readValue<IdentityFile>(file.readText()) }.getOrNull() else null
private fun readAliases(file: File): Map<String, String> = if (file.isFile) runCatching { Output.mapper.readValue<Map<String, String>>(file.readText()) }.getOrElse { emptyMap() } else emptyMap()
private fun readRunState(file: File): RunState = if (file.isFile) runCatching { Output.mapper.readValue<RunState>(file.readText()) }.getOrElse { RunState() } else RunState()
}
@@ -53,7 +53,7 @@ object SubscribeCommand {
val timeoutMs = args.flag("timeout")?.toLongOrNull()?.let { it * 1000 }
val filter = RawEventSupport.buildFilter(args)
Context.open(dataDir).use { ctx ->
Context.openOrAnonymous(dataDir).use { ctx ->
ctx.prepare()
val relays = RawEventSupport.queryTargets(ctx, args)
if (relays.isEmpty()) return Output.error("no_relays", "no relays available; pass --relay or run `amy relay add`")
@@ -95,7 +95,7 @@ object SyncCommand {
val down = args.bool("down") || !up
val filter = RawEventSupport.buildFilter(args)
Context.open(dataDir).use { ctx ->
Context.openOrAnonymous(dataDir).use { ctx ->
ctx.prepare()
val localEvents = ctx.store.query<Event>(filter)
val localById = localEvents.associateBy { it.id }
@@ -185,6 +185,7 @@ class NostrSignerPermissionLedger(
* Deliberately conservative: when a kind's blast radius is unclear, it is left out so the user
* is asked rather than surprised.
*/
@Suppress("DEPRECATION") // TorrentCommentEvent is deprecated (NIP-22) but still a reasonable sign kind
val REASONABLE_SIGN_KINDS: Set<Int> =
setOf(
TextNoteEvent.KIND, // 1 — short text notes & replies
@@ -324,7 +324,7 @@ actual class SecureKeyStorage private actual constructor() {
} else {
// Fallback for non-interactive environments (testing, etc.)
print("Enter master password: ")
readLine() ?: throw SecureStorageException("Password required for fallback storage")
readlnOrNull() ?: throw SecureStorageException("Password required for fallback storage")
}
}
return fallbackPassword!!
@@ -326,6 +326,7 @@ data class KindName(
* platform concern layered on top, never a fork of this data.
*/
object KindNames {
@Suppress("DEPRECATION") // registry intentionally names deprecated kinds (GitReply, TorrentComment) for display
val names: Map<Int, KindName> =
mapOf(
AcceptedBadgeSetEvent.KIND to KindName("Accepted Badge Set", "58"),
@@ -83,9 +83,7 @@ class CommandSerializer : StdSerializer<Command>(Command::class.java) {
gen.writeString(cmd.subId)
}
else -> {
null
}
else -> {}
}
gen.writeEndArray()
@@ -2523,9 +2523,7 @@ class QuicConnection(
PathValidator.RecordResult.Stored,
PathValidator.RecordResult.Duplicate,
PathValidator.RecordResult.AlreadyRetired,
-> {
Unit
}
-> {}
PathValidator.RecordResult.PoolFull -> {
// Peer over-issued past its own advertised
@@ -2573,11 +2571,9 @@ class QuicConnection(
// same path before the next outbound packet (which would
// otherwise stamp a now-retired CID).
when (val rotation = pathValidator.forceRotateToHigherSequence()) {
null -> {
Unit
}
// active CID is still valid; nothing to do.
null -> {}
PathValidator.ForcedRotationResult.NoSpareCid -> {
// Watermark forced retirement of the active CID but
// the pool is empty — we have nothing valid to use.
@@ -2613,9 +2609,7 @@ class QuicConnection(
when (val outcome = pathValidator.applyPathResponse(payload)) {
PathValidator.ValidationOutcome.NotValidating,
PathValidator.ValidationOutcome.PayloadMismatch,
-> {
Unit
}
-> {}
is PathValidator.ValidationOutcome.Validated -> {
// Bug-7 fix: a valid PATH_RESPONSE proves the peer
@@ -852,9 +852,7 @@ private fun dispatchFrames(
// peer knows it just violated the spec instead of
// having its bytes silently dropped.
when (stream.receive.insert(frame.offset, frame.data, frame.fin)) {
com.vitorpamplona.quic.stream.ReceiveBuffer.InsertResult.OK -> {
Unit
}
com.vitorpamplona.quic.stream.ReceiveBuffer.InsertResult.OK -> {}
com.vitorpamplona.quic.stream.ReceiveBuffer.InsertResult.OFFSET_PAST_FIN -> {
conn.markClosedExternally(
@@ -165,9 +165,7 @@ class Http3FrameReader(
)
}
when (context) {
StreamContext.UNCHECKED -> {
Unit
}
StreamContext.UNCHECKED -> {}
StreamContext.CONTROL -> {
// §7.2.4: SETTINGS MUST be the first frame on the
@@ -491,9 +491,7 @@ class WtPeerStreamDemux(
}
// no new requests; we don't enforce yet
else -> {
Unit
}
else -> {}
}
}
}