pakstr 0.22.0 → 0.24.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.
@@ -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
+ }
@@ -15,6 +15,18 @@ const pakstrConfig_1 = require("../core/pakstrConfig");
15
15
  const nsite_1 = require("../core/nsite");
16
16
  const zapStore_1 = require("../core/zapStore");
17
17
  const zapstoreConfig_1 = require("../core/zapstoreConfig");
18
+ function imageContentType(filePath) {
19
+ switch (path_1.default.extname(filePath).toLowerCase()) {
20
+ case ".png": return "image/png";
21
+ case ".jpg":
22
+ case ".jpeg": return "image/jpeg";
23
+ case ".gif": return "image/gif";
24
+ case ".webp": return "image/webp";
25
+ case ".svg": return "image/svg+xml";
26
+ case ".ico": return "image/x-icon";
27
+ default: return "application/octet-stream";
28
+ }
29
+ }
18
30
  /** `pakstr publish` — optionally upload the signed APK and publish its Zapstore events. */
19
31
  async function publishCommand(configPath, options = {}) {
20
32
  if (options.blossomFetch && options.fetchImpl) {
@@ -108,6 +120,15 @@ async function publishCommand(configPath, options = {}) {
108
120
  console.log("Publishing as:", publishNsec.envVar);
109
121
  console.log("Publisher npub:", npub);
110
122
  console.log("Relay:", config.publish.relay);
123
+ const iconUrl = zapstoreConfig?.icon
124
+ ? (await (0, blossom_1.uploadToBlossom)({
125
+ serverUrl: config.publish.blossom,
126
+ filePath: zapstoreConfig.icon,
127
+ secret: publishNsec.bytes,
128
+ contentType: imageContentType(zapstoreConfig.icon),
129
+ fetchImpl,
130
+ })).url
131
+ : undefined;
111
132
  app = {
112
133
  appId: config.app.appId,
113
134
  appName: zapstoreConfig?.name ?? config.app.appName,
@@ -118,6 +139,7 @@ async function publishCommand(configPath, options = {}) {
118
139
  tags: zapstoreConfig?.tags,
119
140
  license: zapstoreConfig?.license,
120
141
  website: zapstoreConfig?.website,
142
+ icon: iconUrl,
121
143
  releaseNotes: zapstoreConfig?.releaseNotes,
122
144
  repository: zapstoreConfig?.repository ?? (0, zapstoreConfig_1.loadZapstoreRepository)(config.configDir),
123
145
  };
@@ -77,6 +77,7 @@ async function buildAppMetadataEvent(input) {
77
77
  const npub = (0, nostr_1.pubkeyHexToNpub)(pubkeyHex);
78
78
  const communities = input.communities?.length ? input.communities : [exports.DEFAULT_COMMUNITY];
79
79
  const summaryTags = input.app.summary !== undefined ? [["summary", input.app.summary]] : [];
80
+ const iconTags = input.app.icon !== undefined ? [["icon", input.app.icon]] : [];
80
81
  const topicTags = (input.app.tags ?? []).map(tag => ["t", tag]);
81
82
  const licenseTags = input.app.license !== undefined ? [["license", input.app.license]] : [];
82
83
  const websiteTags = input.app.website !== undefined ? [["url", input.app.website]] : [];
@@ -88,6 +89,7 @@ async function buildAppMetadataEvent(input) {
88
89
  ["d", input.app.appId],
89
90
  ["name", input.app.appName],
90
91
  ...summaryTags,
92
+ ...iconTags,
91
93
  ["f", PLATFORM],
92
94
  ...communities.map(c => ["h", c]),
93
95
  ...topicTags,
@@ -58,6 +58,10 @@ function loadZapstorePublisherConfig(configDir) {
58
58
  const tags = optionalTags(config.tags, configPath);
59
59
  const license = optionalString(config.license, "license", configPath);
60
60
  const website = optionalString(config.website, "website", configPath);
61
+ const iconPath = optionalString(config.icon, "icon", configPath);
62
+ const icon = iconPath === undefined
63
+ ? undefined
64
+ : resolveIconPath(iconPath, configDir, configPath);
61
65
  const releaseNotesPath = optionalString(config.release_notes, "release_notes", configPath);
62
66
  const releaseNotes = releaseNotesPath === undefined
63
67
  ? undefined
@@ -79,6 +83,7 @@ function loadZapstorePublisherConfig(configDir) {
79
83
  tags,
80
84
  license,
81
85
  website,
86
+ icon,
82
87
  releaseNotes,
83
88
  };
84
89
  }
@@ -94,7 +99,7 @@ function optionalString(value, field, configPath, trim = true) {
94
99
  if (typeof value !== "string") {
95
100
  throw new ZapstoreConfigError(`${exports.ZAPSTORE_CONFIG_FILENAME} field ${field} must be a string`, configPath);
96
101
  }
97
- if (value.trim().length === 0 && field === "release_notes") {
102
+ if (value.trim().length === 0 && (field === "release_notes" || field === "icon")) {
98
103
  throw new ZapstoreConfigError(`${exports.ZAPSTORE_CONFIG_FILENAME} field ${field} must not be empty`, configPath);
99
104
  }
100
105
  return trim ? value.trim() : value;
@@ -112,6 +117,19 @@ function optionalTags(value, configPath) {
112
117
  return tag.trim();
113
118
  });
114
119
  }
120
+ function resolveIconPath(iconPath, configDir, configPath) {
121
+ const resolved = path_1.default.resolve(configDir, iconPath);
122
+ if (!fs_1.default.existsSync(resolved) || !fs_1.default.lstatSync(resolved).isFile()) {
123
+ throw new ZapstoreConfigError(`${exports.ZAPSTORE_CONFIG_FILENAME} icon file not found: ${resolved}`, configPath);
124
+ }
125
+ try {
126
+ fs_1.default.accessSync(resolved, fs_1.default.constants.R_OK);
127
+ }
128
+ catch (error) {
129
+ throw new ZapstoreConfigError(`Could not read ${exports.ZAPSTORE_CONFIG_FILENAME} icon file ${resolved}: ${error instanceof Error ? error.message : String(error)}`, configPath);
130
+ }
131
+ return resolved;
132
+ }
115
133
  function readReleaseNotes(releaseNotesPath, configDir, configPath) {
116
134
  const resolved = path_1.default.resolve(configDir, releaseNotesPath);
117
135
  if (!fs_1.default.existsSync(resolved) || !fs_1.default.lstatSync(resolved).isFile()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pakstr",
3
- "version": "0.22.0",
3
+ "version": "0.24.0",
4
4
  "description": "CLI for packaging Nostr web apps into Android APKs",
5
5
  "type": "commonjs",
6
6
  "main": "dist/index.js",