From c999d2ac73f6fd14e758124d093d85402801968d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 15:28:28 +0000 Subject: [PATCH] feat(napplet): permissions management screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A modern Material3 screen to review and revoke the permissions napplets hold. Data layer (commons, tested): NappletPermissionStore gains all() (enumerate persisted grants by coordinate) and remove(coordinate, capability); the ledger gains allPersistedGrants() and revoke(identity, capability). DataStore actual implements both (capability is the final space-delimited token of each key). UI (amethyst): NappletPermissionsScreen renders one ElevatedCard per napplet — resolved title + author, and a row per capability with an icon, label, and a control: a Switch (Allowed/Blocked) for normal capabilities, or a "Blocked" indicator for per-use ones (payments only ever persist a DENY). Each row has a revoke action; each card a "Forget this napplet" (revokeAll). Empty state with a shield. Reads/writes the same DataStore the broker uses, so changes take effect immediately. Reached via a "Manage permissions" action on the Napplets top bar (Route.NappletPermissions). commons ledger tests added for allPersistedGrants + single-capability revoke. :commons:jvmTest and :amethyst:compileFdroidDebugKotlin pass; spotless clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde --- .../DataStoreNappletPermissionStore.kt | Bin 3890 -> 5075 bytes .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../napplets/NappletPermissionsScreen.kt | 351 ++++++++++++++++++ .../loggedIn/napplets/NappletsScreen.kt | 19 +- amethyst/src/main/res/values/strings.xml | 7 + .../permissions/NappletPermissionLedger.kt | 12 + .../permissions/NappletPermissionStore.kt | 19 + .../NappletPermissionLedgerTest.kt | 30 ++ 9 files changed, 440 insertions(+), 2 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletPermissionsScreen.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletPermissionStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletPermissionStore.kt index dc462c1202b9a825ff6d7b9dd765b08e95424f2b..cd7cc65d4fa2c2bb637da4497cedcddafee67f6e 100644 GIT binary patch delta 477 zcmZ{g&q@MO6vlB;sbjMU5`whDY?=Wb_i54?LZYQ%1Z{$=p0P{wk2`k+QE}b2yXXyC zN3YVVS4et?W=y6ex|?(P&iCVd=e*|M4qvWiv6tD+63bGxBRD5srOO$0hK1<=m_85> zsj^t4vOBRHaMH7I)y=1G=cN)faSYT4rP6m@HlPmSScS@|(J^X`R^#fXs+57>A@qqy zdeowPX^#{$G+W?%q>se`TGXZ-2f*EXbo4a*dZW-5;upk5I0h#yx*zm>&gNlS!{o?X z#OG?a-YI~hXn)x_**YH5H8fp@|KrpIbv0V2e% { WorkoutsScreen(accountViewModel, nav) } composableFromEnd { SoftwareAppsScreen(accountViewModel, nav) } composableFromEnd { NappletsScreen(accountViewModel, nav) } + composableFromEnd { NappletPermissionsScreen(accountViewModel, nav) } composableFromEndArgs { SoftwareAppDetailScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEnd { CalendarsScreen(accountViewModel, nav) } composableFromEnd { CalendarCollectionsScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 3a9efb17ff..cc6fe514bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -89,6 +89,8 @@ sealed class Route { @Serializable object Napplets : Route() + @Serializable object NappletPermissions : Route() + @Serializable data class SoftwareAppDetail( val kind: Int, val pubKeyHex: HexKey, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletPermissionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletPermissionsScreen.kt new file mode 100644 index 0000000000..2bfc11e264 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletPermissionsScreen.kt @@ -0,0 +1,351 @@ +/* + * 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.ui.screen.loggedIn.napplets + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.napplet.NappletCapability +import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity +import com.vitorpamplona.amethyst.commons.napplet.permissions.GrantState +import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger +import com.vitorpamplona.amethyst.napplet.DataStoreNappletPermissionStore +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent +import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest +import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** One napplet's persisted permission grants, ready to render. */ +private data class NappletGrantsUi( + val identity: NappletIdentity, + val title: String, + val capabilities: List>, +) + +@Composable +fun NappletPermissionsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val context = LocalContext.current + val ledger = remember { NappletPermissionLedger(DataStoreNappletPermissionStore(context)) } + + var items by remember { mutableStateOf?>(null) } + var reload by remember { mutableIntStateOf(0) } + + LaunchedEffect(reload) { + items = withContext(Dispatchers.Default) { loadGrants(ledger) } + } + + val scope = rememberCoroutineScope() + + fun mutate(block: suspend () -> Unit) { + scope.launch { + block() + reload++ + } + } + + Scaffold( + topBar = { TopBarWithBackButton(stringResource(R.string.napplet_permissions), nav) }, + ) { padding -> + val current = items + when { + current == null -> + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + + current.isEmpty() -> EmptyState(Modifier.fillMaxSize().padding(padding)) + + else -> + LazyColumn( + modifier = Modifier.fillMaxSize().padding(padding), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(current, key = { it.identity.coordinate }) { napplet -> + NappletPermissionCard( + napplet = napplet, + onSetAllowed = { cap, allowed -> + mutate { ledger.record(napplet.identity, cap, if (allowed) GrantState.ALLOW_ALWAYS else GrantState.DENY) } + }, + onRevoke = { cap -> mutate { ledger.revoke(napplet.identity, cap) } }, + onForget = { mutate { ledger.revokeAll(napplet.identity) } }, + ) + } + } + } + } +} + +@Composable +private fun EmptyState(modifier: Modifier) { + Box(modifier, contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(32.dp), + ) { + Icon( + MaterialSymbols.Shield, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(56.dp), + ) + Text( + stringResource(R.string.napplet_permissions_empty), + style = MaterialTheme.typography.titleMedium, + ) + Text( + stringResource(R.string.napplet_permissions_empty_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun NappletPermissionCard( + napplet: NappletGrantsUi, + onSetAllowed: (NappletCapability, Boolean) -> Unit, + onRevoke: (NappletCapability) -> Unit, + onForget: () -> Unit, +) { + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.size(44.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + MaterialSymbols.Apps, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(24.dp), + ) + } + } + Spacer(Modifier.size(12.dp)) + Column(Modifier.weight(1f)) { + Text( + napplet.title, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + napplet.identity.authorPubKey.take(12) + "…", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + + napplet.capabilities.forEach { (cap, grant) -> + CapabilityRow( + capability = cap, + grant = grant, + onSetAllowed = { onSetAllowed(cap, it) }, + onRevoke = { onRevoke(cap) }, + ) + } + + TextButton( + onClick = onForget, + modifier = Modifier.align(Alignment.End), + ) { + Icon(MaterialSymbols.Delete, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.size(6.dp)) + Text(stringResource(R.string.napplet_permissions_forget)) + } + } + } +} + +@Composable +private fun CapabilityRow( + capability: NappletCapability, + grant: GrantState, + onSetAllowed: (Boolean) -> Unit, + onRevoke: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), + ) { + Icon( + capability.symbol(), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp), + ) + Spacer(Modifier.size(12.dp)) + Column(Modifier.weight(1f)) { + Text(capability.label(), style = MaterialTheme.typography.bodyLarge) + Text( + capability.description(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + if (capability.requiresPerUseConsent) { + // Payments only ever persist a DENY; the user can clear it to allow per-payment prompts again. + Text( + stringResource(R.string.napplet_permissions_blocked), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.error, + ) + } else { + Switch( + checked = grant == GrantState.ALLOW_ALWAYS, + onCheckedChange = onSetAllowed, + ) + } + + Spacer(Modifier.size(4.dp)) + IconButton(onClick = onRevoke) { + Icon( + MaterialSymbols.Block, + contentDescription = stringResource(R.string.napplet_permissions_revoke), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + } +} + +private suspend fun loadGrants(ledger: NappletPermissionLedger): List = + ledger + .allPersistedGrants() + .map { (coordinate, caps) -> + val author = coordinate.substringBefore(':') + val identifier = coordinate.substringAfter(':', "") + NappletGrantsUi( + identity = NappletIdentity(authorPubKey = author, identifier = identifier), + title = resolveTitle(author, identifier), + capabilities = caps.entries.sortedBy { it.key.ordinal }.map { it.key to it.value }, + ) + }.sortedBy { it.title.lowercase() } + +/** Best-effort human title from a cached manifest; falls back to the d-identifier. */ +private fun resolveTitle( + author: String, + identifier: String, +): String { + val events = + Amethyst.instance.cache + .filter(Filter(kinds = listOf(RootNappletEvent.KIND, NamedNappletEvent.KIND), authors = listOf(author))) + .mapNotNull { it.event } + val match = + events.firstOrNull { ev -> + when (ev) { + is NamedNappletEvent -> ev.identifier() == identifier + is RootNappletEvent -> identifier.isEmpty() + else -> false + } + } + return (match as? NappletManifest)?.title()?.ifBlank { null } + ?: identifier.ifBlank { "Napplet" } +} + +private fun NappletCapability.label(): String = + when (this) { + NappletCapability.IDENTITY -> "Identity" + NappletCapability.RELAY -> "Relays" + NappletCapability.WALLET -> "Wallet" + NappletCapability.STORAGE -> "Storage" + NappletCapability.NET -> "Network" + } + +private fun NappletCapability.description(): String = + when (this) { + NappletCapability.IDENTITY -> "Sign and encrypt as you" + NappletCapability.RELAY -> "Read and publish your events" + NappletCapability.WALLET -> "Pay Lightning invoices" + NappletCapability.STORAGE -> "Its own private storage" + NappletCapability.NET -> "Direct network access" + } + +private fun NappletCapability.symbol(): MaterialSymbol = + when (this) { + NappletCapability.IDENTITY -> MaterialSymbols.Key + NappletCapability.RELAY -> MaterialSymbols.Public + NappletCapability.WALLET -> MaterialSymbols.Bolt + NappletCapability.STORAGE -> MaterialSymbols.Storage + NappletCapability.NET -> MaterialSymbols.Language + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsScreen.kt index 031e20ddf8..5c404beb3c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -45,9 +46,13 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.napplet.NappletLauncher import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.topbars.MyExtensibleTopAppBar +import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.NappletsFilterAssemblerSubscription import com.vitorpamplona.quartz.nip01Core.core.Event @@ -78,7 +83,17 @@ fun NappletsScreen( }.collectAsStateWithLifecycle(emptyList()) Scaffold( - topBar = { TopBarWithBackButton(stringResource(R.string.napplets), nav) }, + topBar = { + MyExtensibleTopAppBar( + title = { Text(stringResource(R.string.napplets)) }, + navigationIcon = { IconButton(onClick = { nav.popBack() }) { ArrowBackIcon() } }, + actions = { + IconButton(onClick = { nav.nav(Route.NappletPermissions) }) { + Icon(MaterialSymbols.Tune, contentDescription = stringResource(R.string.napplet_manage_permissions)) + } + }, + ) + }, ) { padding -> if (napplets.isEmpty()) { Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 314408e0c8..08323d63a0 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -734,6 +734,13 @@ Apps Apps Napplets + Napplet permissions + Manage permissions + No napplet permissions yet + Permissions you grant to napplets will appear here. + Forget this napplet + Blocked + Revoke Source: %1$s v%1$s Download diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/permissions/NappletPermissionLedger.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/permissions/NappletPermissionLedger.kt index 3be301549a..25c503f4d9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/permissions/NappletPermissionLedger.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/permissions/NappletPermissionLedger.kt @@ -88,6 +88,18 @@ class NappletPermissionLedger( } } + /** All persisted standing grants, keyed by coordinate — for the permissions-management UI. */ + suspend fun allPersistedGrants(): Map> = store.all() + + /** Forgets a single capability grant for [identity] — both persisted and in-session. */ + suspend fun revoke( + identity: NappletIdentity, + capability: NappletCapability, + ) { + lock.withLock { session[identity.coordinate]?.remove(capability) } + store.remove(identity.coordinate, capability) + } + /** Forgets all grants for [identity] — both the persisted and the in-session ones. */ suspend fun revokeAll(identity: NappletIdentity) { lock.withLock { session.remove(identity.coordinate) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/permissions/NappletPermissionStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/permissions/NappletPermissionStore.kt index afd2271ea0..ec0d44b4bb 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/permissions/NappletPermissionStore.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/permissions/NappletPermissionStore.kt @@ -41,6 +41,15 @@ interface NappletPermissionStore { ) suspend fun clear(coordinate: String) + + /** All persisted grants, keyed by coordinate — used by the permissions-management UI. */ + suspend fun all(): Map> + + /** Removes a single persisted grant (revoking one capability for one napplet). */ + suspend fun remove( + coordinate: String, + capability: NappletCapability, + ) } /** A thread-safe in-memory [NappletPermissionStore] for tests and ephemeral sessions. */ @@ -66,4 +75,14 @@ class InMemoryNappletPermissionStore : NappletPermissionStore { data.remove(coordinate) Unit } + + override suspend fun all(): Map> = lock.withLock { data.mapValues { it.value.toMap() } } + + override suspend fun remove( + coordinate: String, + capability: NappletCapability, + ) = lock.withLock { + data[coordinate]?.remove(capability) + Unit + } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/permissions/NappletPermissionLedgerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/permissions/NappletPermissionLedgerTest.kt index 5630d44dc4..f9e1a8f395 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/permissions/NappletPermissionLedgerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/permissions/NappletPermissionLedgerTest.kt @@ -108,6 +108,36 @@ class NappletPermissionLedgerTest { assertEquals(PermissionDecision.ASK, ledger.decide(applet, NappletCapability.RELAY)) } + @Test + fun allPersistedGrantsListsPersistedDecisionsByCoordinate() = + runTest { + val ledger = ledger() + ledger.record(applet, NappletCapability.IDENTITY, GrantState.ALLOW_ALWAYS) + ledger.record(applet, NappletCapability.RELAY, GrantState.DENY) + ledger.record(applet, NappletCapability.STORAGE, GrantState.ALLOW_SESSION) // not persisted + ledger.record(other, NappletCapability.WALLET, GrantState.DENY) + + val all = ledger.allPersistedGrants() + assertEquals( + mapOf(NappletCapability.IDENTITY to GrantState.ALLOW_ALWAYS, NappletCapability.RELAY to GrantState.DENY), + all[applet.coordinate], + ) + assertEquals(mapOf(NappletCapability.WALLET to GrantState.DENY), all[other.coordinate]) + } + + @Test + fun revokeForgetsOnlyThatCapability() = + runTest { + val ledger = ledger() + ledger.record(applet, NappletCapability.IDENTITY, GrantState.ALLOW_ALWAYS) + ledger.record(applet, NappletCapability.RELAY, GrantState.ALLOW_ALWAYS) + + ledger.revoke(applet, NappletCapability.IDENTITY) + + assertEquals(PermissionDecision.ASK, ledger.decide(applet, NappletCapability.IDENTITY)) + assertEquals(PermissionDecision.ALLOW, ledger.decide(applet, NappletCapability.RELAY)) + } + @Test fun aggregateHashDoesNotAffectTheLedgerKey() = runTest {