pakstr 0.21.1 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (20) hide show
  1. package/README.md +1 -1
  2. package/android-template/app/src/androidTest/java/com/pakstr/app/Nip55CallbackTest.kt +1 -1
  3. package/android-template/app/src/main/java/com/pakstr/app/LocalServer.kt +12 -28
  4. package/android-template/app/src/main/java/com/pakstr/app/Nip55Callback.kt +3 -0
  5. package/android-template/app/src/main/java/com/pakstr/app/Nip55SignEventRequest.kt +303 -0
  6. package/android-template/app/src/main/java/com/pakstr/app/PakstrBridge.kt +14 -2
  7. package/android-template/app/src/main/java/com/pakstr/app/RuntimeApiBaseSettings.kt +98 -0
  8. package/android-template/app/src/main/java/com/pakstr/app/RuntimeApiBaseValidator.kt +30 -0
  9. package/android-template/app/src/main/java/com/pakstr/app/RuntimeConfig.kt +7 -12
  10. package/android-template/app/src/main/java/com/pakstr/app/WebViewController.kt +67 -17
  11. package/android-template/app/src/main/java/com/pakstr/app/debug/DeveloperToolsActivity.kt +70 -4
  12. package/android-template/app/src/main/res/layout/activity_developer_tools.xml +257 -66
  13. package/android-template/app/src/main/res/values/strings.xml +24 -5
  14. package/android-template/app/src/test/java/com/pakstr/app/Nip55SignEventDispatchTest.kt +115 -0
  15. package/android-template/app/src/test/java/com/pakstr/app/Nip55SignEventRequestTest.kt +90 -0
  16. package/android-template/app/src/test/java/com/pakstr/app/PakstrBridgeRuntimeApiBaseTest.kt +52 -0
  17. package/android-template/app/src/test/java/com/pakstr/app/RuntimeApiBaseProxyTest.kt +49 -0
  18. package/android-template/app/src/test/java/com/pakstr/app/RuntimeApiBaseSettingsTest.kt +107 -0
  19. package/android-template/app/src/test/java/com/pakstr/app/RuntimeApiBaseValidatorTest.kt +50 -0
  20. package/package.json +1 -1
@@ -0,0 +1,115 @@
1
+ package com.pakstr.app
2
+
3
+ import com.pakstr.app.signer.SignerType
4
+ import kotlinx.coroutines.runBlocking
5
+ import org.json.JSONArray
6
+ import org.json.JSONObject
7
+ import org.junit.Assert.assertEquals
8
+ import org.junit.Assert.assertFalse
9
+ import org.junit.Assert.assertTrue
10
+ import org.junit.Test
11
+
12
+ class Nip55SignEventDispatchTest {
13
+ private val callback = "pakstr-test://nip55-callback#nip55_event="
14
+ private val activePubkey = "1".repeat(64)
15
+
16
+ @Test
17
+ fun bunker_valid_sign_event_signs_internally_without_external_intent() {
18
+ val result = dispatch(SignerType.BUNKER, validRequest())
19
+
20
+ assertTrue(result.signed)
21
+ assertFalse(result.forwarded)
22
+ assertEquals(SIGNED_EVENT, result.delivered)
23
+ }
24
+
25
+ @Test
26
+ fun bunker_invalid_sign_event_is_consumed_without_external_intent() {
27
+ val result = dispatch(
28
+ SignerType.BUNKER,
29
+ validRequest().replace("returnType=event", "returnType=signature")
30
+ )
31
+
32
+ assertFalse(result.signed)
33
+ assertFalse(result.forwarded)
34
+ assertTrue(result.rejection != null)
35
+ }
36
+
37
+ @Test
38
+ fun bunker_other_operation_is_forwarded_externally() {
39
+ val result = dispatch(
40
+ SignerType.BUNKER,
41
+ validRequest().replace("type=sign_event", "type=get_public_key")
42
+ )
43
+
44
+ assertFalse(result.signed)
45
+ assertTrue(result.forwarded)
46
+ }
47
+
48
+ @Test
49
+ fun amber_sign_event_is_forwarded_externally() {
50
+ val result = dispatch(SignerType.AMBER, validRequest())
51
+
52
+ assertFalse(result.signed)
53
+ assertTrue(result.forwarded)
54
+ }
55
+
56
+ private fun dispatch(type: SignerType, rawUrl: String): Result {
57
+ var forwarded = false
58
+ var signed = false
59
+ var delivered: String? = null
60
+ var rejection: String? = null
61
+
62
+ dispatchNip55SignEvent(
63
+ rawUrl = rawUrl,
64
+ activeSignerType = type,
65
+ expectedCallbackUrl = callback,
66
+ forwardExternally = { forwarded = true },
67
+ launchSigning = { operation -> runBlocking { operation() } },
68
+ getActivePubkey = { activePubkey },
69
+ signEvent = {
70
+ signed = true
71
+ SIGNED_EVENT
72
+ },
73
+ deliverSignedEvent = { delivered = it },
74
+ logRejection = { rejection = it }
75
+ )
76
+
77
+ return Result(forwarded, signed, delivered, rejection)
78
+ }
79
+
80
+ private fun validRequest(): String {
81
+ val event = JSONObject()
82
+ .put("kind", 27235)
83
+ .put("created_at", 1789552517L)
84
+ .put("tags", JSONArray())
85
+ .put("content", "")
86
+ .toString()
87
+ return "nostrsigner:${encode(event)}" +
88
+ "?type=sign_event&returnType=event&compressionType=none" +
89
+ "&callbackUrl=${encode(callback)}"
90
+ }
91
+
92
+ private fun encode(value: String): String {
93
+ return value.toByteArray(Charsets.UTF_8).joinToString("") { byte ->
94
+ val valueInt = byte.toInt() and 0xff
95
+ val character = valueInt.toChar()
96
+ if (character.isLetterOrDigit() || character in "-._~") {
97
+ character.toString()
98
+ } else {
99
+ "%${valueInt.toString(16).uppercase().padStart(2, '0')}"
100
+ }
101
+ }
102
+ }
103
+
104
+ private data class Result(
105
+ val forwarded: Boolean,
106
+ val signed: Boolean,
107
+ val delivered: String?,
108
+ val rejection: String?
109
+ )
110
+
111
+ private companion object {
112
+ const val SIGNED_EVENT =
113
+ "{\"id\":\"signed-id\",\"pubkey\":\"signed-pubkey\",\"sig\":\"signed-sig\"}"
114
+ }
115
+ }
@@ -0,0 +1,90 @@
1
+ package com.pakstr.app
2
+
3
+ import org.json.JSONObject
4
+ import org.junit.Assert.assertEquals
5
+ import org.junit.Assert.assertTrue
6
+ import org.junit.Test
7
+
8
+ class Nip55SignEventRequestTest {
9
+ private val callback =
10
+ "pakstr-7f7e720bd582301afabb6b441f89a4fb8d1a481c3e0286c69345d03e7cb1929c://nip55-callback#nip55_event="
11
+
12
+ @Test
13
+ fun parses_exact_regress_request() {
14
+ val rawUrl =
15
+ "nostrsigner:%7B%22kind%22%3A27235%2C%22created_at%22%3A1789552517%2C%22content%22%3A%22%22%2C%22tags%22%3A%5B%5B%22u%22%2C%22http%3A%2F%2F127.0.0.1%3A8080%2Fapi%2Fauth%2Flogin%22%5D%2C%5B%22method%22%2C%22POST%22%5D%5D%7D?compressionType=none&returnType=event&type=sign_event&callbackUrl=pakstr-7f7e720bd582301afabb6b441f89a4fb8d1a481c3e0286c69345d03e7cb1929c%3A%2F%2Fnip55-callback%23nip55_event%3D"
16
+
17
+ val parsed = Nip55SignEventParser.parse(rawUrl, callback)
18
+
19
+ assertTrue(parsed is Nip55SignEventRequest.Parsed)
20
+ parsed as Nip55SignEventRequest.Parsed
21
+ assertEquals(callback, parsed.callbackUrl)
22
+ assertEquals(27235, parsed.event.getInt("kind"))
23
+ assertEquals(1789552517L, parsed.event.getLong("created_at"))
24
+ assertEquals("", parsed.event.getString("content"))
25
+ assertEquals(
26
+ "http://127.0.0.1:8080/api/auth/login",
27
+ parsed.event.getJSONArray("tags").getJSONArray(0).getString(1)
28
+ )
29
+ }
30
+
31
+ @Test
32
+ fun accepts_event_return_with_none_or_omitted_compression() {
33
+ val base = requestQuery("type=sign_event&returnType=event&callbackUrl=${encode(callback)}")
34
+ val withNone = requestQuery(
35
+ "type=sign_event&returnType=event&compressionType=none&callbackUrl=${encode(callback)}"
36
+ )
37
+
38
+ assertTrue(Nip55SignEventParser.parse(base, callback) is Nip55SignEventRequest.Parsed)
39
+ assertTrue(Nip55SignEventParser.parse(withNone, callback) is Nip55SignEventRequest.Parsed)
40
+ }
41
+
42
+ @Test
43
+ fun rejects_malformed_or_unsupported_sign_event() {
44
+ val unsupported = requestQuery(
45
+ "type=sign_event&returnType=event&compressionType=gzip&callbackUrl=${encode(callback)}"
46
+ )
47
+ val malformed =
48
+ "nostrsigner:%7Bbad?type=sign_event&returnType=event&callbackUrl=${encode(callback)}"
49
+
50
+ assertTrue(
51
+ Nip55SignEventParser.parse(unsupported, callback) is Nip55SignEventRequest.Rejected
52
+ )
53
+ assertTrue(
54
+ Nip55SignEventParser.parse(malformed, callback) is Nip55SignEventRequest.Rejected
55
+ )
56
+ }
57
+
58
+ @Test
59
+ fun rejects_foreign_callback() {
60
+ val rawUrl = requestQuery(
61
+ "type=sign_event&returnType=event&callbackUrl=${encode("other://nip55-callback#nip55_event=")}"
62
+ )
63
+
64
+ assertTrue(
65
+ Nip55SignEventParser.parse(rawUrl, callback) is Nip55SignEventRequest.Rejected
66
+ )
67
+ }
68
+
69
+ private fun requestQuery(query: String): String {
70
+ val event = JSONObject()
71
+ .put("kind", 27235)
72
+ .put("created_at", 1789552517L)
73
+ .put("tags", org.json.JSONArray())
74
+ .put("content", "")
75
+ .toString()
76
+ return "nostrsigner:${encode(event)}?$query"
77
+ }
78
+
79
+ private fun encode(value: String): String {
80
+ return value.toByteArray(Charsets.UTF_8).joinToString("") { byte ->
81
+ val valueInt = byte.toInt() and 0xff
82
+ val character = valueInt.toChar()
83
+ if (character.isLetterOrDigit() || character in "-._~") {
84
+ character.toString()
85
+ } else {
86
+ "%${valueInt.toString(16).uppercase().padStart(2, '0')}"
87
+ }
88
+ }
89
+ }
90
+ }
@@ -0,0 +1,52 @@
1
+ package com.pakstr.app
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertFalse
5
+ import org.junit.Assert.assertTrue
6
+ import org.junit.Test
7
+
8
+ class PakstrBridgeRuntimeApiBaseTest {
9
+ @Test
10
+ fun setterPersistsValidatedOverrideAndGetterReturnsEffectiveValue() {
11
+ val preferences = FakePreferences()
12
+ val bridge = PakstrBridge(
13
+ RuntimeApiBaseSettings.createForTesting(preferences) {
14
+ "https://default.example.com"
15
+ }
16
+ )
17
+
18
+ assertTrue(bridge.setApiBaseUrl("https://override.example.com/"))
19
+ assertEquals("https://override.example.com", bridge.getApiBaseUrl())
20
+ assertFalse(bridge.setApiBaseUrl("http://unsafe.example.com"))
21
+ assertEquals("https://override.example.com", bridge.getApiBaseUrl())
22
+ }
23
+
24
+ @Test
25
+ fun setterReportsPersistenceFailure() {
26
+ val bridge = PakstrBridge(
27
+ RuntimeApiBaseSettings.createForTesting(FakePreferences(commitResult = false)) {
28
+ null
29
+ }
30
+ )
31
+
32
+ assertFalse(bridge.setApiBaseUrl("https://api.example.com"))
33
+ }
34
+
35
+ private class FakePreferences(
36
+ private val commitResult: Boolean = true
37
+ ) : RuntimeApiBaseSettings.Preferences {
38
+ private val values = mutableMapOf<String, String>()
39
+
40
+ override fun getString(key: String): String? = values[key]
41
+
42
+ override fun putString(key: String, value: String): Boolean {
43
+ if (commitResult) values[key] = value
44
+ return commitResult
45
+ }
46
+
47
+ override fun remove(key: String): Boolean {
48
+ if (commitResult) values.remove(key)
49
+ return commitResult
50
+ }
51
+ }
52
+ }
@@ -0,0 +1,49 @@
1
+ package com.pakstr.app
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertTrue
5
+ import org.junit.Test
6
+
7
+ class RuntimeApiBaseProxyTest {
8
+ @Test
9
+ fun proxyUsesUpdatedEffectiveValueOnSubsequentRequest() {
10
+ val preferences = FakePreferences()
11
+ val settings = RuntimeApiBaseSettings.createForTesting(preferences) {
12
+ "https://default.example.com"
13
+ }
14
+
15
+ val firstRequestApiBase = settings.current().url!!
16
+ assertEquals(
17
+ "https://default.example.com/api/items",
18
+ proxyApiUrl(firstRequestApiBase, "/api/items").toString()
19
+ )
20
+
21
+ assertTrue(settings.setOverride("https://updated.example.com/base/"))
22
+
23
+ val secondRequestApiBase = settings.current().url!!
24
+ assertEquals(
25
+ "https://updated.example.com/base/api/items",
26
+ proxyApiUrl(secondRequestApiBase, "/api/items").toString()
27
+ )
28
+ assertEquals(
29
+ "https://default.example.com/api/items",
30
+ proxyApiUrl(firstRequestApiBase, "/api/items").toString()
31
+ )
32
+ }
33
+
34
+ private class FakePreferences : RuntimeApiBaseSettings.Preferences {
35
+ private val values = mutableMapOf<String, String>()
36
+
37
+ override fun getString(key: String): String? = values[key]
38
+
39
+ override fun putString(key: String, value: String): Boolean {
40
+ values[key] = value
41
+ return true
42
+ }
43
+
44
+ override fun remove(key: String): Boolean {
45
+ values.remove(key)
46
+ return true
47
+ }
48
+ }
49
+ }
@@ -0,0 +1,107 @@
1
+ package com.pakstr.app
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertFalse
5
+ import org.junit.Assert.assertNull
6
+ import org.junit.Assert.assertTrue
7
+ import org.junit.Test
8
+
9
+ class RuntimeApiBaseSettingsTest {
10
+ @Test
11
+ fun persistedOverrideTakesPrecedenceOverPackagedDefault() {
12
+ val preferences = FakePreferences()
13
+ val settings = settings(preferences, "https://default.example.com")
14
+
15
+ assertTrue(settings.setOverride("https://override.example.com/"))
16
+ assertEquals(
17
+ "https://override.example.com",
18
+ preferences.values[RuntimeApiBaseSettings.API_BASE_OVERRIDE_KEY]
19
+ )
20
+ assertEquals(
21
+ RuntimeApiBaseSettings.Value(
22
+ "https://override.example.com",
23
+ RuntimeApiBaseSettings.Source.RUNTIME_OVERRIDE
24
+ ),
25
+ settings.current()
26
+ )
27
+ }
28
+
29
+ @Test
30
+ fun resetRemovesOverrideAndRestoresPackagedDefault() {
31
+ val preferences = FakePreferences()
32
+ val settings = settings(preferences, "https://default.example.com/")
33
+ settings.setOverride("https://override.example.com")
34
+
35
+ assertTrue(settings.reset())
36
+ assertFalse(preferences.values.containsKey(RuntimeApiBaseSettings.API_BASE_OVERRIDE_KEY))
37
+ assertEquals(
38
+ RuntimeApiBaseSettings.Value(
39
+ "https://default.example.com",
40
+ RuntimeApiBaseSettings.Source.PACKAGED_DEFAULT
41
+ ),
42
+ settings.current()
43
+ )
44
+ }
45
+
46
+ @Test
47
+ fun noConfiguredDefaultDisablesApiProxy() {
48
+ val settings = settings(FakePreferences(), null)
49
+
50
+ assertEquals(
51
+ RuntimeApiBaseSettings.Value(
52
+ null,
53
+ RuntimeApiBaseSettings.Source.DISABLED
54
+ ),
55
+ settings.current()
56
+ )
57
+ }
58
+
59
+ @Test
60
+ fun invalidPersistedValueIsIgnored() {
61
+ val preferences = FakePreferences(
62
+ mutableMapOf(
63
+ RuntimeApiBaseSettings.API_BASE_OVERRIDE_KEY to "http://unsafe.example.com"
64
+ )
65
+ )
66
+
67
+ assertEquals(
68
+ "https://default.example.com",
69
+ settings(preferences, "https://default.example.com").current().url
70
+ )
71
+ assertNull(settings(preferences, null).current().url)
72
+ }
73
+
74
+ @Test
75
+ fun invalidOverrideDoesNotPersist() {
76
+ val preferences = FakePreferences()
77
+ val settings = settings(preferences, "https://default.example.com")
78
+
79
+ assertFalse(settings.setOverride("https://user@example.com"))
80
+ assertTrue(preferences.values.isEmpty())
81
+ }
82
+
83
+ private fun settings(
84
+ preferences: FakePreferences,
85
+ packagedDefault: String?
86
+ ): RuntimeApiBaseSettings = RuntimeApiBaseSettings.createForTesting(
87
+ preferences
88
+ ) {
89
+ packagedDefault
90
+ }
91
+
92
+ private class FakePreferences(
93
+ val values: MutableMap<String, String> = mutableMapOf()
94
+ ) : RuntimeApiBaseSettings.Preferences {
95
+ override fun getString(key: String): String? = values[key]
96
+
97
+ override fun putString(key: String, value: String): Boolean {
98
+ values[key] = value
99
+ return true
100
+ }
101
+
102
+ override fun remove(key: String): Boolean {
103
+ values.remove(key)
104
+ return true
105
+ }
106
+ }
107
+ }
@@ -0,0 +1,50 @@
1
+ package com.pakstr.app
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertNull
5
+ import org.junit.Test
6
+
7
+ class RuntimeApiBaseValidatorTest {
8
+ @Test
9
+ fun acceptsAbsoluteHttpsUrlWithHostname() {
10
+ assertEquals(
11
+ "https://api.example.com/v1",
12
+ RuntimeApiBaseValidator.validateAndCanonicalize(
13
+ "https://api.example.com/v1"
14
+ )
15
+ )
16
+ }
17
+
18
+ @Test
19
+ fun normalizesTrailingSlashesAndHostCase() {
20
+ assertEquals(
21
+ "https://api.example.com/v1",
22
+ RuntimeApiBaseValidator.validateAndCanonicalize(
23
+ "HTTPS://API.Example.COM/v1///"
24
+ )
25
+ )
26
+ assertEquals(
27
+ "https://api.example.com",
28
+ RuntimeApiBaseValidator.validateAndCanonicalize(
29
+ "https://api.example.com/"
30
+ )
31
+ )
32
+ }
33
+
34
+ @Test
35
+ fun rejectsInvalidOrUnsafeUrls() {
36
+ listOf(
37
+ "",
38
+ " https://api.example.com",
39
+ "http://api.example.com",
40
+ "api.example.com",
41
+ "https:///v1",
42
+ "https://user:password@api.example.com",
43
+ "https://api.example.com?v=1",
44
+ "https://api.example.com#fragment",
45
+ "https://api.example.com/${"a".repeat(RuntimeApiBaseValidator.MAX_LENGTH)}"
46
+ ).forEach { value ->
47
+ assertNull(value, RuntimeApiBaseValidator.validateAndCanonicalize(value))
48
+ }
49
+ }
50
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pakstr",
3
- "version": "0.21.1",
3
+ "version": "0.23.0",
4
4
  "description": "CLI for packaging Nostr web apps into Android APKs",
5
5
  "type": "commonjs",
6
6
  "main": "dist/index.js",