refactor(commons): move the napplet wire protocol and scheduled posts to commonMain

Tier 4 of the commons migration sweep: two jvmAndroid groups that were each
pinned by a single JVM API.

Napplet: NappletProtocolJson (and NappletIdentityWatch, NappletRequestRouter,
which only waited on it) used java.util.Base64 and System.currentTimeMillis.
Now kotlin.io.encoding.Base64 and TimeUtils.now(). The decoder uses
PaddingOption.PRESENT_OPTIONAL because java.util.Base64.getDecoder() accepted
unpadded input; a test pins that.

Scheduled posts: ScheduledPostStore was pinned by java.io.File and a POSIX
chmod. It now takes an okio Path + FileSystem (plus a String constructor, the
FileSystemNip95BlobStore shape, so callers need no okio). Owner-only
permissions are a new expect fun restrictFileToOwner: Files.setPosix... on
jvmAndroid, chmod(0600) on iOS. The temp-file swap is FileSystem.atomicMove,
which replaces an existing target everywhere, so the rename/delete/rename
fallback is gone. The work gate, publisher and notifier came along.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjQQN9CgWHVtNCSWqnKoqY
This commit is contained in:
Claude
2026-09-26 21:12:49 +00:00
parent 5be5e3cae4
commit 08169f8497
16 changed files with 175 additions and 56 deletions
@@ -1154,7 +1154,7 @@ class AppModules(
// Local store for posts the user has scheduled to publish later. Backed by a
// single JSON file under the app's private filesDir; read by ScheduledPostWorker.
val scheduledPostStore =
ScheduledPostStore(File(appContext.filesDir, ScheduledPostStore.FILE_NAME))
ScheduledPostStore(File(appContext.filesDir, ScheduledPostStore.FILE_NAME).path)
// Organizes cache clearing
val trimmingService by
@@ -184,6 +184,13 @@ class NappletProtocolJsonTest {
assertEquals("Hi", up.bytes.decodeToString())
}
@Test
fun uploadAcceptsUnpaddedBase64() {
// java.util.Base64 decoded "SGk" (no '=') as "Hi"; the commonMain decoder must too.
val up = NappletProtocolJson.decodeRequest("""{"type":"upload.upload","request":{"dataBase64":"SGk"}}""") as NappletRequest.UploadBlob
assertEquals("Hi", up.bytes.decodeToString())
}
@Test
fun unknownTypeDecodesToNull() {
assertNull(NappletProtocolJson.decodeRequest("""{"type":"inc.emit","id":"1"}"""))
@@ -57,7 +57,7 @@ class ScheduledPostWorkGateTest {
decisions.clear()
}
private fun newStore() = ScheduledPostStore(file)
private fun newStore() = ScheduledPostStore(file.path)
private fun TestScope.startGate(store: ScheduledPostStore) =
ScheduledPostWorkGate(
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.napplet.protocol
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
@@ -37,7 +38,7 @@ import kotlinx.serialization.json.long
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
import kotlinx.serialization.json.putJsonObject
import java.util.Base64
import kotlin.io.encoding.Base64
/**
* Marshals the KMP-pure [NappletRequest] / [NappletResponse] types to and from the wire the
@@ -45,10 +46,10 @@ import java.util.Base64
* (`@napplet/shim` / `@napplet/nap`): requests are `{ "type": "<domain>.<action>", "id", ...fields }`
* and replies are `{ "type": "<domain>.<action>.result", "id", ...fields }`.
*
* Lives in `commons/jvmAndroid` (not in any single front end) because the wire contract is identical
* Lives in `commons/commonMain` (not in any single front end) because the wire contract is identical
* across hosts: the Android `:napplet` WebView host and a future desktop host both marshal through
* here. It depends only on `quartz` (Event/Filter), kotlinx.serialization, and `java.util.Base64`
* (available on Android API 26+ and the JVM) — no platform-UI or process APIs.
* here. It depends only on `quartz` (Event/Filter), kotlinx.serialization and `kotlin.io.encoding`
* — no platform-UI or process APIs.
*
* This is the only place the boundary parses untrusted applet input, so it is deliberately
* strict: an unrecognized `type` decodes to `null` (the broker denies it) and a malformed/short
@@ -58,6 +59,9 @@ import java.util.Base64
object NappletProtocolJson {
private val json = Json { ignoreUnknownKeys = true }
// Accepts unpadded input, as java.util.Base64's decoder did before this moved to commonMain.
private val lenientBase64 = Base64.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL)
/** The `type` discriminant of a request envelope, used to build the matching `.result` type. */
fun readType(envelopeJson: String): String? = json.parseToJsonElement(envelopeJson).jsonObject.str("type")
@@ -166,7 +170,7 @@ object NappletProtocolJson {
kind = t.kindOf(),
tags = decodeTags(t),
content = t.str("content") ?: "",
createdAt = t["created_at"]?.jsonPrimitive?.long ?: (System.currentTimeMillis() / 1000),
createdAt = t["created_at"]?.jsonPrimitive?.long ?: TimeUtils.now(),
)
}
// NIP-07 nip44.encrypt/decrypt: crypto only, no publish. `peer` is the counterparty
@@ -194,7 +198,7 @@ object NappletProtocolJson {
// The Blob in `request.data` is inlined as base64 `request.dataBase64` by shell.html.
val request = o.getValue("request").jsonObject
NappletRequest.UploadBlob(
bytes = Base64.getDecoder().decode(request.req("dataBase64")),
bytes = lenientBase64.decode(request.req("dataBase64")),
contentType = request.str("mimeType") ?: "application/octet-stream",
filename = request.str("filename"),
)
@@ -275,7 +279,7 @@ object NappletProtocolJson {
}
is NappletResponse.Bytes -> {
put("ok", true)
put("bytes", Base64.getEncoder().encodeToString(response.bytes))
put("bytes", Base64.encode(response.bytes))
put("mime", response.contentType)
}
is NappletResponse.ResourceInfo -> {
@@ -301,7 +305,7 @@ object NappletProtocolJson {
put("url", item.url)
put("ok", item.resource != null)
item.resource?.let {
put("bytes", Base64.getEncoder().encodeToString(it.bytes))
put("bytes", Base64.encode(it.bytes))
put("mime", it.contentType)
}
item.error?.let { put("error", it) }
@@ -20,21 +20,31 @@
*/
package com.vitorpamplona.amethyst.commons.scheduledposts
import com.vitorpamplona.amethyst.commons.util.platformFileSystem
import com.vitorpamplona.amethyst.commons.util.restrictFileToOwner
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.json.Json
import java.io.File
import java.nio.file.Files
import java.nio.file.attribute.PosixFilePermission
import okio.FileSystem
import okio.Path
import okio.Path.Companion.toPath
class ScheduledPostStore(
private val storageFile: File,
private val nowSec: () -> Long = { System.currentTimeMillis() / 1000 },
private val storageFile: Path,
private val nowSec: () -> Long = { TimeUtils.now() },
private val fileSystem: FileSystem = platformFileSystem,
) {
/** Takes the platform's own path string, so callers need no okio of their own. */
constructor(
storagePath: String,
nowSec: () -> Long = { TimeUtils.now() },
) : this(storagePath.toPath(), nowSec)
/**
* `encodeDefaults = true` so this writes the same bytes Jackson did — kotlinx
* omits a value equal to its default, which would silently drop `"version":1`
@@ -214,7 +224,7 @@ class ScheduledPostStore(
*/
suspend fun publishNow(
id: String,
nowSec: Long = System.currentTimeMillis() / 1000,
nowSec: Long = TimeUtils.now(),
): Boolean =
mutex.withLock {
ensureLoaded()
@@ -336,8 +346,8 @@ class ScheduledPostStore(
private fun reloadFromDiskLocked() {
val fromDisk =
try {
if (storageFile.exists() && storageFile.length() > 0) {
json.decodeFromString<ScheduledPostFile>(storageFile.readText()).posts.toMutableList()
if (hasContent()) {
json.decodeFromString<ScheduledPostFile>(readText()).posts.toMutableList()
} else {
// File vanished — treat as no external state; keep current in-memory.
return
@@ -354,8 +364,8 @@ class ScheduledPostStore(
if (loaded) return
posts =
try {
if (storageFile.exists() && storageFile.length() > 0) {
json.decodeFromString<ScheduledPostFile>(storageFile.readText()).posts.toMutableList()
if (hasContent()) {
json.decodeFromString<ScheduledPostFile>(readText()).posts.toMutableList()
} else {
mutableListOf()
}
@@ -367,7 +377,7 @@ class ScheduledPostStore(
// Secure a pre-existing file up front (an older build may have left it at the
// 0644 umask default) so it's owner-only even if nothing mutates the store
// this session.
if (storageFile.exists()) restrictToOwner(storageFile)
if (fileSystem.exists(storageFile)) restrictFileToOwner(storageFile, TAG)
var dirty = purgeStale(nowSec())
// Recover claims stranded by a crash-mid-publish so they aren't lost forever.
if (recoverStuckClaimsLocked(nowSec())) dirty = true
@@ -412,48 +422,30 @@ class ScheduledPostStore(
private fun persist() {
val snapshot = posts.toList()
_flow.value = snapshot
storageFile.parentFile?.mkdirs()
val tmp = File(storageFile.parentFile, storageFile.name + ".tmp")
val tmp = "$storageFile.tmp".toPath()
try {
tmp.writeText(json.encodeToString(ScheduledPostFile(version = 1, posts = snapshot)))
storageFile.parent?.let { fileSystem.createDirectories(it) }
fileSystem.write(tmp) { writeUtf8(json.encodeToString(ScheduledPostFile(version = 1, posts = snapshot))) }
// Restrict to owner-only BEFORE the rename so the store is never briefly
// world-readable. It holds pre-signed events + the account's pubkey, which
// must not leak to other local users on a shared machine.
restrictToOwner(tmp)
if (!tmp.renameTo(storageFile)) {
if (!storageFile.delete()) {
Log.w(TAG) { "Failed to delete existing $storageFile before rename retry" }
}
if (!tmp.renameTo(storageFile)) {
Log.e(TAG) { "Failed to rename $tmp to $storageFile" }
if (!tmp.delete()) {
Log.w(TAG) { "Failed to clean up temp file $tmp after rename failure" }
}
}
}
restrictFileToOwner(tmp, TAG)
// Replaces an existing target, including on Windows, where a plain rename
// onto an existing file fails.
fileSystem.atomicMove(tmp, storageFile)
} catch (e: Exception) {
Log.e(TAG, "Failed to persist scheduled posts to $storageFile", e)
if (!tmp.delete()) {
Log.w(TAG) { "Failed to clean up temp file $tmp after persist exception" }
try {
fileSystem.delete(tmp)
} catch (e: Exception) {
Log.w(TAG) { "Failed to clean up temp file $tmp after persist exception: ${e.message}" }
}
}
}
/**
* Best-effort chmod to `0600` (owner read/write only). No-op on filesystems
* without POSIX permissions (e.g. Windows), where confidentiality relies on the
* per-user home directory instead. Never throws.
*/
private fun restrictToOwner(file: File) {
try {
Files.setPosixFilePermissions(
file.toPath(),
setOf(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE),
)
} catch (e: Exception) {
Log.w(TAG) { "Could not restrict permissions on $file: ${e.message}" }
}
}
private fun hasContent(): Boolean = (fileSystem.metadataOrNull(storageFile)?.size ?: 0L) > 0L
private fun readText(): String = fileSystem.read(storageFile) { readUtf8() }
companion object {
private const val TAG = "ScheduledPostStore"
@@ -0,0 +1,34 @@
/*
* 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.commons.util
import okio.Path
/**
* Restricts the file at [path] to owner read/write (`0600`), best-effort. Never throws.
*
* Silent where the filesystem has no POSIX permissions (Windows), where the user profile's
* ACLs apply instead; logs under [tag] when a POSIX filesystem refuses.
*/
expect fun restrictFileToOwner(
path: Path,
tag: String,
)
@@ -0,0 +1,40 @@
/*
* 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.commons.util
import com.vitorpamplona.quartz.utils.Log
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.convert
import okio.Path
import platform.posix.S_IRUSR
import platform.posix.S_IWUSR
import platform.posix.chmod
import platform.posix.errno
@OptIn(ExperimentalForeignApi::class)
actual fun restrictFileToOwner(
path: Path,
tag: String,
) {
if (chmod(path.toString(), (S_IRUSR or S_IWUSR).convert()) != 0) {
Log.w(tag) { "Could not restrict permissions on $path: errno $errno" }
}
}
@@ -0,0 +1,42 @@
/*
* 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.commons.util
import com.vitorpamplona.quartz.utils.Log
import okio.Path
import java.nio.file.Files
import java.nio.file.attribute.PosixFilePermission
actual fun restrictFileToOwner(
path: Path,
tag: String,
) {
try {
Files.setPosixFilePermissions(
path.toNioPath(),
setOf(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE),
)
} catch (_: UnsupportedOperationException) {
// Windows: no POSIX permissions; the user profile's NTFS ACLs apply instead.
} catch (e: Exception) {
Log.w(tag) { "Could not restrict permissions on $path: ${e.message}" }
}
}
@@ -47,7 +47,7 @@ class ScheduledPostStoreTest {
file = File(temp.root, "scheduled_posts.json")
}
private fun newStore(now: () -> Long = { System.currentTimeMillis() / 1000 }) = ScheduledPostStore(file, now)
private fun newStore(now: () -> Long = { System.currentTimeMillis() / 1000 }) = ScheduledPostStore(file.path, now)
private fun samplePost(
id: String = "id-1",
@@ -52,7 +52,7 @@ object DesktopScheduledPostStore {
if (file.exists()) {
setOwnerOnly(file, PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE)
}
return ScheduledPostStore(file)
return ScheduledPostStore(file.path)
}
private fun setDirPermissions(dir: File) =
@@ -82,7 +82,7 @@ fun runHeadlessPublish(): Int {
}
private fun headlessDrain(log: (String) -> Unit): Int {
val store = ScheduledPostStore(File(scheduledDir(), ScheduledPostStore.FILE_NAME))
val store = ScheduledPostStore(File(scheduledDir(), ScheduledPostStore.FILE_NAME).path)
// Build a plain OkHttp client for websockets — no Tor, no proxy. Headless mode
// reads no settings, so we cannot know Tor preferences; scheduled posts that