native-update 1.4.8 → 2.0.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 (77) hide show
  1. package/Readme.md +13 -1
  2. package/android/src/main/java/com/aoneahsan/nativeupdate/BackgroundUpdatePlugin.kt +15 -0
  3. package/android/src/main/java/com/aoneahsan/nativeupdate/BackgroundUpdateWorker.kt +23 -7
  4. package/android/src/main/java/com/aoneahsan/nativeupdate/LiveUpdatePlugin.kt +152 -4
  5. package/android/src/main/java/com/aoneahsan/nativeupdate/NativeUpdatePlugin.kt +14 -1
  6. package/android/src/main/java/com/aoneahsan/nativeupdate/NotificationActionReceiver.kt +10 -1
  7. package/android/src/main/java/com/aoneahsan/nativeupdate/SecurityManager.kt +18 -18
  8. package/cli/AGENTS.md +29 -0
  9. package/cli/CLAUDE.md +51 -0
  10. package/dist/esm/__tests__/security-enforcement.test.d.ts +1 -0
  11. package/dist/esm/__tests__/security-enforcement.test.js +95 -0
  12. package/dist/esm/__tests__/security-enforcement.test.js.map +1 -0
  13. package/dist/esm/core/config.d.ts +6 -15
  14. package/dist/esm/core/config.js +1 -4
  15. package/dist/esm/core/config.js.map +1 -1
  16. package/dist/esm/core/security.d.ts +11 -3
  17. package/dist/esm/core/security.js +19 -6
  18. package/dist/esm/core/security.js.map +1 -1
  19. package/dist/esm/definitions.d.ts +38 -24
  20. package/dist/esm/definitions.js.map +1 -1
  21. package/dist/esm/firestore/firestore-client.js +4 -0
  22. package/dist/esm/firestore/firestore-client.js.map +1 -1
  23. package/dist/esm/firestore/schema.d.ts +2 -0
  24. package/dist/esm/firestore/schema.js.map +1 -1
  25. package/dist/esm/index.d.ts +1 -2
  26. package/dist/esm/index.js +0 -2
  27. package/dist/esm/index.js.map +1 -1
  28. package/dist/esm/live-update/download-manager.d.ts +36 -5
  29. package/dist/esm/live-update/download-manager.js +61 -22
  30. package/dist/esm/live-update/download-manager.js.map +1 -1
  31. package/dist/esm/live-update/update-manager.d.ts +12 -1
  32. package/dist/esm/live-update/update-manager.js +38 -10
  33. package/dist/esm/live-update/update-manager.js.map +1 -1
  34. package/dist/esm/live-update/version-manager.d.ts +9 -0
  35. package/dist/esm/live-update/version-manager.js +40 -0
  36. package/dist/esm/live-update/version-manager.js.map +1 -1
  37. package/dist/esm/plugin.js +95 -175
  38. package/dist/esm/plugin.js.map +1 -1
  39. package/dist/esm/web.d.ts +18 -1
  40. package/dist/esm/web.js +69 -24
  41. package/dist/esm/web.js.map +1 -1
  42. package/dist/plugin.cjs.js +1 -1
  43. package/dist/plugin.cjs.js.map +1 -1
  44. package/dist/plugin.esm.js +1 -1
  45. package/dist/plugin.esm.js.map +1 -1
  46. package/dist/plugin.js +2 -2
  47. package/dist/plugin.js.map +1 -1
  48. package/docs/AGENTS.md +38 -0
  49. package/docs/CHANGELOG.md +167 -0
  50. package/docs/CLAUDE.md +101 -0
  51. package/docs/MIGRATION.md +87 -0
  52. package/docs/README.md +13 -2
  53. package/docs/deployment/HOSTINGER_DEPLOY.md +329 -0
  54. package/docs/features/laravel-nova-backend/ASSESSMENT-SUMMARY.md +96 -0
  55. package/docs/features/laravel-nova-backend/IMPLEMENTATION-PLAN.md +504 -0
  56. package/docs/features/laravel-nova-backend/credentials.ignore.md +34 -0
  57. package/docs/features/laravel-nova-backend/progress-tracker.json +184 -0
  58. package/docs/guides/no-cost-backend-implementation-plan.md +77 -0
  59. package/docs/guides/no-cost-firestore-google-drive-backend.md +60 -0
  60. package/docs/project-knowledge-base/01-system-overview.md +218 -0
  61. package/docs/project-knowledge-base/02-routes-pages-forms-users.md +346 -0
  62. package/docs/project-knowledge-base/03-tech-stack-modules-services.md +347 -0
  63. package/docs/project-knowledge-base/04-data-models-integrations.md +307 -0
  64. package/docs/project-knowledge-base/05-docs-corpus-inventory.md +193 -0
  65. package/docs/project-knowledge-base/06-operations-testing-legal-content.md +170 -0
  66. package/docs/project-knowledge-base/README.md +90 -0
  67. package/docs/project-profiles/native-update-capacitor-update-platform-project-profile-last-updated-2026-03-16.md +454 -0
  68. package/docs/project-profiles/native-update-capacitor-update-platform-project-profile-last-updated-2026-03-24.md +66 -0
  69. package/docs/project-profiles/native-update-capacitor-update-platform-project-profile-last-updated-2026-03-25.md +67 -0
  70. package/docs/seo-aeo-rules.json +3043 -0
  71. package/docs/tracking/seo-checklist-tracker.json +333 -0
  72. package/ios/Plugin/BackgroundUpdate/BackgroundUpdatePlugin.swift +50 -6
  73. package/ios/Plugin/LiveUpdate/LiveUpdatePlugin.swift +238 -8
  74. package/ios/Plugin/NativeUpdatePlugin.swift +8 -0
  75. package/ios/Plugin/Security/SecurityManager.swift +13 -14
  76. package/package.json +31 -32
  77. package/docs/play-console-rejection-rules.json +0 -428
package/Readme.md CHANGED
@@ -13,6 +13,18 @@
13
13
  >
14
14
  > **🚀 Try the example apps in `example-apps/` to see all features in action!**
15
15
 
16
+ ## Current State
17
+
18
+ - Package version: `1.4.9`
19
+ - Verified on: `2026-03-25`
20
+ - Tests: `81/81` passing via `yarn test:run`
21
+ - Build: `yarn build` succeeds
22
+ - Root package manager is standardized on Yarn, and the root scripts no longer shell out to npm or pnpm
23
+ - Root portfolio info file:
24
+ - `NATIVE-UPDATE_portfolio-info_2026-03-25.md`
25
+ - Current dated project profile:
26
+ - `docs/project-profiles/native-update-capacitor-update-platform-project-profile-last-updated-2026-03-25.md`
27
+
16
28
  ## 📚 Documentation
17
29
 
18
30
  - **[AI Integration Guide](https://nativeupdate.aoneahsan.com/docs/AI-INTEGRATION-GUIDE)** - Quick reference for AI development agents (Claude, Cursor, Copilot)
@@ -61,7 +73,7 @@
61
73
 
62
74
  ---
63
75
 
64
- A **foundation package** for building a comprehensive update management plugin for Capacitor that combines Live/OTA updates, native app store updates, and in-app review capabilities. This package provides the architecture, interfaces, and documentation but requires additional implementation work.
76
+ A production-oriented Capacitor update platform that combines Live/OTA updates, native app store update flows, in-app reviews, release tooling, dashboard support, example backends, and operational documentation in one ecosystem.
65
77
 
66
78
  ## 🌐 Dashboard & Management Platform
67
79
 
@@ -81,6 +81,15 @@ class BackgroundUpdatePlugin : Plugin() {
81
81
  val workRequest = OneTimeWorkRequestBuilder<BackgroundUpdateWorker>()
82
82
  .setInputData(createWorkData())
83
83
  .addTag(WORK_TAG)
84
+ // Exponential backoff starting at 30s. WorkManager's default
85
+ // is 10s which hits a failing server too fast and burns
86
+ // battery on mobile. 30s base, doubled each retry, caps
87
+ // automatically at WorkManager's internal max (~5 hours).
88
+ .setBackoffCriteria(
89
+ BackoffPolicy.EXPONENTIAL,
90
+ 30,
91
+ TimeUnit.SECONDS
92
+ )
84
93
  .build()
85
94
 
86
95
  WorkManager.getInstance(context)
@@ -141,6 +150,12 @@ class BackgroundUpdatePlugin : Plugin() {
141
150
  .setConstraints(constraints)
142
151
  .setInputData(createWorkData())
143
152
  .addTag(WORK_TAG)
153
+ // See triggerBackgroundCheck — same 30s exponential base.
154
+ .setBackoffCriteria(
155
+ BackoffPolicy.EXPONENTIAL,
156
+ 30,
157
+ TimeUnit.SECONDS
158
+ )
144
159
  .build()
145
160
 
146
161
  WorkManager.getInstance(context)
@@ -17,31 +17,47 @@ class BackgroundUpdateWorker(
17
17
  companion object {
18
18
  private const val TAG = "BackgroundUpdateWorker"
19
19
  private const val NOTIFICATION_ID = 1001
20
+ // Cap the number of automatic retries for a transient failure
21
+ // (network error, backend 5xx, etc). WorkManager does not cap
22
+ // retries natively for our periodic request — without this guard
23
+ // a persistently-failing server would burn battery indefinitely.
24
+ // The schedule (with exponential backoff starting at 30s) takes
25
+ // roughly 30+60+120+240+480 = ~15 minutes across 5 retries, after
26
+ // which we give up until the next periodic run.
27
+ private const val MAX_RETRY_ATTEMPTS = 5
20
28
  }
21
-
29
+
22
30
  override suspend fun doWork(): Result {
23
31
  return withContext(Dispatchers.IO) {
24
32
  try {
33
+ if (runAttemptCount >= MAX_RETRY_ATTEMPTS) {
34
+ android.util.Log.w(
35
+ TAG,
36
+ "Giving up after $runAttemptCount retries; will try again on next periodic run"
37
+ )
38
+ return@withContext Result.failure()
39
+ }
40
+
25
41
  val configJson = inputData.getString("config")
26
42
  val config = parseConfig(configJson)
27
-
43
+
28
44
  if (config == null || !config.enabled) {
29
45
  return@withContext Result.failure()
30
46
  }
31
-
47
+
32
48
  val result = performBackgroundCheck(config)
33
-
49
+
34
50
  // Update plugin status
35
51
  updatePluginStatus(result)
36
-
52
+
37
53
  // Send notification if updates found
38
54
  if (result.updatesFound) {
39
55
  sendNotification(result)
40
56
  }
41
-
57
+
42
58
  // Notify listeners
43
59
  notifyListeners(result)
44
-
60
+
45
61
  if (result.success) Result.success() else Result.retry()
46
62
  } catch (e: Exception) {
47
63
  android.util.Log.e(TAG, "Background update failed", e)
@@ -25,6 +25,24 @@ class LiveUpdatePlugin(
25
25
  private val securityManager = SecurityManager(context)
26
26
  private val activeDownloads = mutableMapOf<String, Long>()
27
27
  private var downloadManager: android.app.DownloadManager? = null
28
+
29
+ companion object {
30
+ // SharedPreferences keys. Kept as constants so A2 (boot-time
31
+ // re-verification) and A3 (crash-loop auto-rollback) stay in sync
32
+ // with the call-sites in setActiveBundle / notifyAppReady.
33
+ private const val PREF_FILE = "native_update"
34
+ private const val ACTIVE_BUNDLE_KEY = "active_bundle"
35
+ private const val PREVIOUS_BUNDLE_KEY = "previous_active_bundle"
36
+ private const val PENDING_BUNDLE_KEY = "pending_verify_bundle"
37
+ private const val PENDING_ATTEMPTS_KEY = "pending_verify_attempts"
38
+
39
+ // Activation of a new bundle is "pending" until JS calls
40
+ // notifyAppReady(). Each cold start before that bumps the counter.
41
+ // Two failed cold starts is a strong signal the bundle crashes
42
+ // the app, so we auto-rollback to the previous bundle on the
43
+ // second failure.
44
+ private const val MAX_PENDING_ATTEMPTS = 2
45
+ }
28
46
 
29
47
  init {
30
48
  // Initialize OkHttp with default settings
@@ -178,6 +196,11 @@ class LiveUpdatePlugin(
178
196
  bundleInfo.put("status", "READY")
179
197
  bundleInfo.put("checksum", checksum)
180
198
  bundleInfo.put("verified", true)
199
+ // Persist signature so the boot-time re-verify in
200
+ // verifyActiveBundleOnBoot() can run without re-downloading.
201
+ if (signature != null) {
202
+ bundleInfo.put("signature", signature)
203
+ }
181
204
 
182
205
  // Save bundle info
183
206
  saveBundleInfo(bundleInfo)
@@ -428,8 +451,23 @@ class LiveUpdatePlugin(
428
451
  }
429
452
 
430
453
  private fun setActiveBundle(bundleId: String) {
431
- val prefs = context.getSharedPreferences("native_update", Context.MODE_PRIVATE)
432
- prefs.edit().putString("active_bundle", bundleId).apply()
454
+ val prefs = context.getSharedPreferences(PREF_FILE, Context.MODE_PRIVATE)
455
+ val currentActive = prefs.getString(ACTIVE_BUNDLE_KEY, null)
456
+ val editor = prefs.edit().putString(ACTIVE_BUNDLE_KEY, bundleId)
457
+
458
+ // Preserve the previous active bundle as a rollback target. Skip
459
+ // self-activation (setting to the same bundle) so we don't nuke
460
+ // a real previous-bundle pointer.
461
+ if (currentActive != null && currentActive != bundleId) {
462
+ editor.putString(PREVIOUS_BUNDLE_KEY, currentActive)
463
+ }
464
+
465
+ // Mark this activation as pending-verify. notifyAppReady() in the
466
+ // host app clears it; otherwise the next cold start increments the
467
+ // attempt counter and eventually triggers auto-rollback.
468
+ editor.putString(PENDING_BUNDLE_KEY, bundleId)
469
+ editor.putInt(PENDING_ATTEMPTS_KEY, 0)
470
+ editor.apply()
433
471
  }
434
472
 
435
473
  private fun clearAllBundles() {
@@ -508,8 +546,118 @@ class LiveUpdatePlugin(
508
546
  }
509
547
 
510
548
  private fun markBundleAsVerified() {
511
- val prefs = context.getSharedPreferences("native_update", Context.MODE_PRIVATE)
512
- prefs.edit().putBoolean("current_bundle_verified", true).apply()
549
+ // notifyAppReady() is the host app's "I booted clean on this bundle"
550
+ // signal. Clear the pending-verify state so cold starts after this
551
+ // no longer count against the bundle.
552
+ val prefs = context.getSharedPreferences(PREF_FILE, Context.MODE_PRIVATE)
553
+ prefs.edit()
554
+ .putBoolean("current_bundle_verified", true)
555
+ .remove(PENDING_BUNDLE_KEY)
556
+ .remove(PENDING_ATTEMPTS_KEY)
557
+ .apply()
558
+ }
559
+
560
+ /**
561
+ * Called from NativeUpdatePlugin.load() on every cold start. Runs two
562
+ * independent defenses, both silent when no OTA bundle is active:
563
+ *
564
+ * A2 — re-hash the active bundle and verify it still matches the
565
+ * checksum stored at install time. If signature + publicKey are
566
+ * configured, also re-verify the signature. This catches on-disk
567
+ * tampering on rooted devices.
568
+ * A3 — if the active bundle was never confirmed by notifyAppReady()
569
+ * on the previous launch, treat this cold start as a failed
570
+ * boot. After MAX_PENDING_ATTEMPTS failures, rollback to the
571
+ * previous known-good bundle to break the crash loop.
572
+ *
573
+ * On any failure the function rolls back and fires an
574
+ * updateStateChanged event with status "ROLLBACK" so the host app and
575
+ * analytics layer can observe it.
576
+ */
577
+ fun verifyActiveBundleOnBoot() {
578
+ val prefs = context.getSharedPreferences(PREF_FILE, Context.MODE_PRIVATE)
579
+
580
+ // A3: crash-loop detection. If a bundle is still pending, this
581
+ // cold start is counted against it.
582
+ val pendingBundleId = prefs.getString(PENDING_BUNDLE_KEY, null)
583
+ if (pendingBundleId != null) {
584
+ val nextAttempt = prefs.getInt(PENDING_ATTEMPTS_KEY, 0) + 1
585
+ if (nextAttempt > MAX_PENDING_ATTEMPTS) {
586
+ rollbackToPrevious("auto-rollback after $nextAttempt failed cold starts", pendingBundleId)
587
+ prefs.edit()
588
+ .remove(PENDING_BUNDLE_KEY)
589
+ .remove(PENDING_ATTEMPTS_KEY)
590
+ .apply()
591
+ return
592
+ }
593
+ prefs.edit().putInt(PENDING_ATTEMPTS_KEY, nextAttempt).apply()
594
+ }
595
+
596
+ // A2: integrity re-verify. Only runs for non-default active bundles.
597
+ val activeBundleId = prefs.getString(ACTIVE_BUNDLE_KEY, null) ?: return
598
+ if (activeBundleId == "default") return
599
+
600
+ val bundleInfo = getBundleInfoById(activeBundleId) ?: run {
601
+ rollbackToPrevious("active bundle info missing from preferences", activeBundleId)
602
+ return
603
+ }
604
+
605
+ val bundlePath = bundleInfo.optString("path", "")
606
+ val expectedChecksum = bundleInfo.optString("checksum", "")
607
+ if (bundlePath.isEmpty() || expectedChecksum.isEmpty()) return
608
+
609
+ val bundleFile = File(bundlePath)
610
+ if (!bundleFile.exists()) {
611
+ rollbackToPrevious("bundle file missing on disk", activeBundleId)
612
+ return
613
+ }
614
+
615
+ if (!verifyChecksum(bundleFile, expectedChecksum)) {
616
+ rollbackToPrevious("bundle checksum mismatch on boot", activeBundleId)
617
+ return
618
+ }
619
+
620
+ val signature = bundleInfo.optString("signature", "")
621
+ if (signature.isNotEmpty()) {
622
+ val publicKey = config?.getJSObject("security")?.getString("publicKey")
623
+ ?: config?.getString("publicKey")
624
+ if (!publicKey.isNullOrEmpty()) {
625
+ val bytes = bundleFile.readBytes()
626
+ if (!securityManager.verifySignature(bytes, signature, publicKey)) {
627
+ rollbackToPrevious("bundle signature mismatch on boot", activeBundleId)
628
+ return
629
+ }
630
+ }
631
+ }
632
+ }
633
+
634
+ private fun rollbackToPrevious(reason: String, fromBundleId: String) {
635
+ val prefs = context.getSharedPreferences(PREF_FILE, Context.MODE_PRIVATE)
636
+ val previous = prefs.getString(PREVIOUS_BUNDLE_KEY, null)
637
+ val editor = prefs.edit()
638
+ if (previous != null) {
639
+ editor.putString(ACTIVE_BUNDLE_KEY, previous)
640
+ } else {
641
+ editor.remove(ACTIVE_BUNDLE_KEY)
642
+ }
643
+ editor.remove(PREVIOUS_BUNDLE_KEY).apply()
644
+
645
+ stateChangeListener?.invoke(JSObject().apply {
646
+ put("status", "ROLLBACK")
647
+ put("bundleId", fromBundleId)
648
+ put("rolledBackTo", previous ?: "default")
649
+ put("reason", reason)
650
+ })
651
+ }
652
+
653
+ private fun getBundleInfoById(bundleId: String): JSObject? {
654
+ val prefs = context.getSharedPreferences("native_update_bundles", Context.MODE_PRIVATE)
655
+ val raw = prefs.getString(bundleId, null) ?: return null
656
+ return try {
657
+ JSObject(raw)
658
+ } catch (e: Exception) {
659
+ null
660
+ }
513
661
  }
514
662
 
515
663
  // Async method for background update checks
@@ -49,10 +49,23 @@ class NativeUpdatePlugin : Plugin() {
49
49
  liveUpdatePlugin.setProgressListener { progress ->
50
50
  notifyListeners("downloadProgress", progress)
51
51
  }
52
-
52
+
53
53
  liveUpdatePlugin.setStateChangeListener { state ->
54
54
  notifyListeners("updateStateChanged", state)
55
55
  }
56
+
57
+ // Boot-time integrity re-verify + crash-loop auto-rollback. Runs
58
+ // before any host-app code touches the WebView so a tampered or
59
+ // crash-looping bundle never gets a chance to load.
60
+ // Signature re-verify runs again after configure() once the
61
+ // publicKey arrives from the host app; checksum re-verify here
62
+ // is sufficient to catch most tampering.
63
+ try {
64
+ liveUpdatePlugin.verifyActiveBundleOnBoot()
65
+ } catch (e: Exception) {
66
+ // Never let boot verification crash the plugin itself — fall
67
+ // through and let a later sync() surface the problem.
68
+ }
56
69
  }
57
70
 
58
71
  @PluginMethod
@@ -4,6 +4,7 @@ import android.content.BroadcastReceiver
4
4
  import android.content.Context
5
5
  import android.content.Intent
6
6
  import android.util.Log
7
+ import androidx.work.BackoffPolicy
7
8
  import androidx.work.ExistingWorkPolicy
8
9
  import androidx.work.OneTimeWorkRequestBuilder
9
10
  import androidx.work.WorkManager
@@ -47,12 +48,20 @@ class NotificationActionReceiver : BroadcastReceiver() {
47
48
  when (updateType) {
48
49
  "live_update" -> {
49
50
  val bundleId = intent.getStringExtra(EXTRA_BUNDLE_ID) ?: return
50
- // Trigger immediate installation through WorkManager
51
+ // Trigger immediate installation through WorkManager.
52
+ // Exponential backoff keeps an ailing network or server
53
+ // from burning battery; matches the policy used by
54
+ // BackgroundUpdatePlugin.triggerBackgroundCheck.
51
55
  val workRequest = OneTimeWorkRequestBuilder<BackgroundUpdateWorker>()
52
56
  .setInputData(workDataOf(
53
57
  "action" to "install_bundle",
54
58
  "bundle_id" to bundleId
55
59
  ))
60
+ .setBackoffCriteria(
61
+ BackoffPolicy.EXPONENTIAL,
62
+ 30,
63
+ java.util.concurrent.TimeUnit.SECONDS
64
+ )
56
65
  .build()
57
66
 
58
67
  WorkManager.getInstance(context).enqueueUniqueWork(
@@ -68,7 +68,8 @@ class SecurityManager(private val context: Context) {
68
68
  result.put("certificatePinning", certificatePinning)
69
69
 
70
70
  result.put("validateInputs", config?.getBool("validateInputs") ?: true)
71
- result.put("secureStorage", config?.getBool("secureStorage") ?: true)
71
+ // Hard-coded true since v2 — secure storage is no longer opt-out.
72
+ result.put("secureStorage", true)
72
73
 
73
74
  return result
74
75
  }
@@ -162,23 +163,20 @@ class SecurityManager(private val context: Context) {
162
163
  return digest.digest().joinToString("") { "%02x".format(it) }
163
164
  }
164
165
 
166
+ /**
167
+ * Writes always land in EncryptedSharedPreferences backed by the
168
+ * Android Keystore master key. The old `secureStorage: false` opt-out
169
+ * was removed in v2 — there is no legitimate reason to store an
170
+ * API key or signing metadata in plaintext on a device, and shipping
171
+ * the option was worse than not shipping it at all (the default was
172
+ * opt-in, not opt-out, so most integrations landed in plaintext).
173
+ */
165
174
  fun saveSecureData(key: String, value: String) {
166
- if (isSecureStorageEnabled()) {
167
- securePrefs.edit().putString(key, value).apply()
168
- } else {
169
- // Fallback to regular preferences (not recommended)
170
- val prefs = context.getSharedPreferences("native_update", Context.MODE_PRIVATE)
171
- prefs.edit().putString(key, value).apply()
172
- }
175
+ securePrefs.edit().putString(key, value).apply()
173
176
  }
174
-
177
+
175
178
  fun getSecureData(key: String): String? {
176
- return if (isSecureStorageEnabled()) {
177
- securePrefs.getString(key, null)
178
- } else {
179
- val prefs = context.getSharedPreferences("native_update", Context.MODE_PRIVATE)
180
- prefs.getString(key, null)
181
- }
179
+ return securePrefs.getString(key, null)
182
180
  }
183
181
 
184
182
  fun validatePath(path: String): Boolean {
@@ -204,9 +202,11 @@ class SecurityManager(private val context: Context) {
204
202
  return config?.getBool("enforceHttps") ?: true
205
203
  }
206
204
 
207
- fun isSecureStorageEnabled(): Boolean {
208
- return config?.getBool("secureStorage") ?: true
209
- }
205
+ /**
206
+ * Secure storage is unconditionally on since v2. Kept as a getter so
207
+ * `getSecurityInfo()` still reports accurately to callers.
208
+ */
209
+ fun isSecureStorageEnabled(): Boolean = true
210
210
 
211
211
  fun isInputValidationEnabled(): Boolean {
212
212
  return config?.getBool("validateInputs") ?: true
package/cli/AGENTS.md ADDED
@@ -0,0 +1,29 @@
1
+ # AGENTS.md - CLI Tool
2
+
3
+ Agent instructions for the native-update CLI tool.
4
+
5
+ ## 🔴 3-Day Freshness Rule
6
+ Check and update this file at least every 3 days. See root AGENTS.md.
7
+
8
+ ## Scope
9
+
10
+ Node.js CLI for release management, bundle operations, and configuration. Distributed as part of the npm package.
11
+
12
+ ## Key Rules
13
+
14
+ 1. **ESM module**: Uses `"type": "module"` — all imports must use ESM syntax
15
+ 2. **Interactive prompts**: Use `prompts` library for user input
16
+ 3. **Error messages**: Clear, actionable error messages without exposing internals
17
+ 4. **Exit codes**: Use proper exit codes (0 success, 1 error)
18
+ 5. **Help text**: Every command must have `--help` documentation
19
+
20
+ ## When Adding CLI Commands
21
+
22
+ 1. Create command file in `commands/`
23
+ 2. Register in `index.js`
24
+ 3. Update `docs/CLI_REFERENCE.md`
25
+ 4. Add usage example to relevant docs
26
+
27
+ ---
28
+
29
+ **Last Updated**: 2026-04-02
package/cli/CLAUDE.md ADDED
@@ -0,0 +1,51 @@
1
+ # Native Update - CLI
2
+
3
+ Command-line interface for native-update release management and bundle operations.
4
+
5
+ ## 🔴 3-Day Freshness Rule
6
+ Check and update this file at least every 3 days. See root CLAUDE.md.
7
+
8
+ ## Structure
9
+
10
+ | File/Folder | Purpose |
11
+ |-------------|---------|
12
+ | `index.js` | CLI entry point (~10KB) |
13
+ | `commands/` | Command implementations |
14
+ | `package.json` | CLI dependencies |
15
+
16
+ ## Dependencies
17
+ - `commander` — Argument parsing
18
+ - `chalk` — Colored terminal output
19
+ - `ora` — Loading spinners
20
+ - `archiver` — ZIP creation
21
+ - `prompts` — Interactive prompts
22
+
23
+ ## Usage
24
+
25
+ ```bash
26
+ # Via npx
27
+ npx native-update <command>
28
+
29
+ # Or install globally
30
+ npm install -g native-update
31
+ native-update <command>
32
+ ```
33
+
34
+ ## Commands
35
+
36
+ The CLI provides commands for:
37
+ - Bundle management (create, verify, sign)
38
+ - Update publishing (upload, activate, rollback)
39
+ - Configuration management
40
+ - Release management
41
+
42
+ ## Module System
43
+ - ESM (`"type": "module"` in package.json)
44
+ - Uses `commander` for CLI framework
45
+
46
+ ## FilesHub Rule (inherited)
47
+ All file storage uses FilesHub API. See root CLAUDE.md.
48
+
49
+ ---
50
+
51
+ **Last Updated**: 2026-04-02
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,95 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { SecurityValidator } from '../core/security';
3
+ import { ConfigManager } from '../core/config';
4
+ import { ErrorCode } from '../core/errors';
5
+ // We assert on shape (name + code + message) rather than `instanceof`
6
+ // because NativeUpdateError pins its prototype in the constructor, which
7
+ // breaks cross-subclass `instanceof` checks in certain transpilation modes.
8
+ /**
9
+ * Regression tests for the signature/checksum fail-closed contract.
10
+ *
11
+ * Prior to v2, verifySignature returned `true` when a signature was missing
12
+ * but a public key was configured, and verifyChecksum returned `true` for
13
+ * any empty checksum. Both were silent-pass gaps that let an attacker ship
14
+ * a manifest with the fields stripped out and bypass all integrity checks.
15
+ *
16
+ * These tests lock the new contract in place:
17
+ * - empty checksum → throws (always required in OTA paths)
18
+ * - empty signature with publicKey/requireSignature → throws
19
+ * - empty signature with neither configured → returns true (opt-out)
20
+ */
21
+ describe('SecurityValidator — fail-closed enforcement', () => {
22
+ const validator = SecurityValidator.getInstance();
23
+ const config = ConfigManager.getInstance();
24
+ const sampleData = new TextEncoder().encode('hello world').buffer;
25
+ // Test-only RSA public key (real format, fake material).
26
+ const SAMPLE_PUBLIC_KEY_PEM = `-----BEGIN PUBLIC KEY-----\n` +
27
+ `MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxxxxxxxxxxxxxxxxxxxx\n` +
28
+ `-----END PUBLIC KEY-----`;
29
+ beforeEach(() => {
30
+ // Reset to defaults before every test.
31
+ config.configure({
32
+ publicKey: '',
33
+ enableSignatureValidation: false,
34
+ requireSignature: false,
35
+ });
36
+ });
37
+ describe('verifyChecksum', () => {
38
+ it('throws when checksum is missing', async () => {
39
+ await expect(validator.verifyChecksum(sampleData, '')).rejects.toMatchObject({
40
+ name: 'ValidationError',
41
+ code: ErrorCode.CHECKSUM_MISMATCH,
42
+ });
43
+ });
44
+ it('returns true on matching checksum', async () => {
45
+ // SHA-256("hello world")
46
+ const expected = 'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9';
47
+ const ok = await validator.verifyChecksum(sampleData, expected);
48
+ expect(ok).toBe(true);
49
+ });
50
+ it('returns false on mismatched checksum', async () => {
51
+ const ok = await validator.verifyChecksum(sampleData, '0000000000000000000000000000000000000000000000000000000000000000');
52
+ expect(ok).toBe(false);
53
+ });
54
+ });
55
+ describe('verifySignature', () => {
56
+ it('throws when signature missing and publicKey is configured', async () => {
57
+ config.set('publicKey', SAMPLE_PUBLIC_KEY_PEM);
58
+ await expect(validator.verifySignature(sampleData, '')).rejects.toMatchObject({
59
+ name: 'ValidationError',
60
+ code: ErrorCode.SIGNATURE_INVALID,
61
+ });
62
+ });
63
+ it('throws when signature missing and requireSignature=true', async () => {
64
+ config.set('requireSignature', true);
65
+ await expect(validator.verifySignature(sampleData, undefined)).rejects.toMatchObject({
66
+ name: 'ValidationError',
67
+ code: ErrorCode.SIGNATURE_INVALID,
68
+ });
69
+ });
70
+ it('throws when signature missing and enableSignatureValidation=true', async () => {
71
+ config.set('enableSignatureValidation', true);
72
+ await expect(validator.verifySignature(sampleData, null)).rejects.toMatchObject({
73
+ name: 'ValidationError',
74
+ code: ErrorCode.SIGNATURE_INVALID,
75
+ });
76
+ });
77
+ it('returns true when signature AND signature-enforcement are both unset', async () => {
78
+ // Opt-out path: host app passed no publicKey and disabled both flags.
79
+ config.set('enableSignatureValidation', false);
80
+ config.set('requireSignature', false);
81
+ const ok = await validator.verifySignature(sampleData, '');
82
+ expect(ok).toBe(true);
83
+ });
84
+ it('throws when signature is provided but publicKey is not configured', async () => {
85
+ config.set('enableSignatureValidation', false);
86
+ config.set('requireSignature', false);
87
+ config.set('publicKey', '');
88
+ await expect(validator.verifySignature(sampleData, 'AAAAAA==')).rejects.toMatchObject({
89
+ name: 'ValidationError',
90
+ code: ErrorCode.SIGNATURE_INVALID,
91
+ });
92
+ });
93
+ });
94
+ });
95
+ //# sourceMappingURL=security-enforcement.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"security-enforcement.test.js","sourceRoot":"","sources":["../../../src/__tests__/security-enforcement.test.ts"],"names":[],"mappings":"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"}
@@ -22,6 +22,12 @@ export interface PluginConfig {
22
22
  serverUrl?: string;
23
23
  channel?: string;
24
24
  appId?: string;
25
+ /**
26
+ * API key for authentication with the Native Update backend
27
+ * Get this from the Native Update dashboard after creating your app
28
+ * Format: nu_app_xxx or nu_device_xxx
29
+ */
30
+ apiKey?: string;
25
31
  autoCheck?: boolean;
26
32
  autoUpdate?: boolean;
27
33
  updateStrategy?: UpdateStrategy;
@@ -58,21 +64,6 @@ export interface PluginConfig {
58
64
  * Allows gradual deployment to a percentage of users
59
65
  */
60
66
  enableStagedRollouts?: boolean;
61
- /**
62
- * Enable bundle encryption (AES-256-GCM)
63
- * When enabled, downloaded bundles will be decrypted before validation
64
- */
65
- enableEncryption?: boolean;
66
- /**
67
- * Encryption key for decrypting bundles
68
- * Should be stored securely and not hardcoded in production
69
- */
70
- encryptionKey?: string;
71
- /**
72
- * Salt for key derivation (base64 encoded)
73
- * Required when using password-based encryption
74
- */
75
- encryptionSalt?: string;
76
67
  }
77
68
  export declare class ConfigManager {
78
69
  private static instance;
@@ -25,6 +25,7 @@ export class ConfigManager {
25
25
  serverUrl: '',
26
26
  channel: 'production',
27
27
  appId: '',
28
+ apiKey: '',
28
29
  autoCheck: true,
29
30
  autoUpdate: false,
30
31
  updateStrategy: 'background',
@@ -53,10 +54,6 @@ export class ConfigManager {
53
54
  firestore: null,
54
55
  enableDeltaUpdates: true,
55
56
  enableStagedRollouts: true,
56
- // Encryption configuration
57
- enableEncryption: false,
58
- encryptionKey: '',
59
- encryptionSalt: '',
60
57
  };
61
58
  }
62
59
  configure(config) {