native-update 1.4.9 → 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.
- package/Readme.md +13 -1
- package/android/src/main/java/com/aoneahsan/nativeupdate/BackgroundUpdatePlugin.kt +15 -0
- package/android/src/main/java/com/aoneahsan/nativeupdate/BackgroundUpdateWorker.kt +23 -7
- package/android/src/main/java/com/aoneahsan/nativeupdate/LiveUpdatePlugin.kt +152 -4
- package/android/src/main/java/com/aoneahsan/nativeupdate/NativeUpdatePlugin.kt +14 -1
- package/android/src/main/java/com/aoneahsan/nativeupdate/NotificationActionReceiver.kt +10 -1
- package/android/src/main/java/com/aoneahsan/nativeupdate/SecurityManager.kt +18 -18
- package/cli/AGENTS.md +29 -0
- package/cli/CLAUDE.md +51 -0
- package/dist/esm/__tests__/security-enforcement.test.d.ts +1 -0
- package/dist/esm/__tests__/security-enforcement.test.js +95 -0
- package/dist/esm/__tests__/security-enforcement.test.js.map +1 -0
- package/dist/esm/core/config.d.ts +6 -15
- package/dist/esm/core/config.js +1 -4
- package/dist/esm/core/config.js.map +1 -1
- package/dist/esm/core/security.d.ts +11 -3
- package/dist/esm/core/security.js +19 -6
- package/dist/esm/core/security.js.map +1 -1
- package/dist/esm/definitions.d.ts +13 -29
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/index.d.ts +0 -2
- package/dist/esm/index.js +0 -2
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/live-update/download-manager.d.ts +36 -5
- package/dist/esm/live-update/download-manager.js +61 -22
- package/dist/esm/live-update/download-manager.js.map +1 -1
- package/dist/esm/live-update/update-manager.d.ts +12 -1
- package/dist/esm/live-update/update-manager.js +38 -10
- package/dist/esm/live-update/update-manager.js.map +1 -1
- package/dist/esm/live-update/version-manager.d.ts +9 -0
- package/dist/esm/live-update/version-manager.js +40 -0
- package/dist/esm/live-update/version-manager.js.map +1 -1
- package/dist/esm/plugin.js +13 -46
- package/dist/esm/plugin.js.map +1 -1
- package/dist/esm/web.d.ts +18 -1
- package/dist/esm/web.js +69 -24
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +1 -1
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.esm.js +1 -1
- package/dist/plugin.esm.js.map +1 -1
- package/dist/plugin.js +2 -2
- package/dist/plugin.js.map +1 -1
- package/docs/AGENTS.md +38 -0
- package/docs/CHANGELOG.md +151 -0
- package/docs/CLAUDE.md +101 -0
- package/docs/MIGRATION.md +87 -0
- package/docs/README.md +13 -2
- package/docs/deployment/HOSTINGER_DEPLOY.md +329 -0
- package/docs/features/laravel-nova-backend/ASSESSMENT-SUMMARY.md +96 -0
- package/docs/features/laravel-nova-backend/IMPLEMENTATION-PLAN.md +504 -0
- package/docs/features/laravel-nova-backend/credentials.ignore.md +34 -0
- package/docs/features/laravel-nova-backend/progress-tracker.json +184 -0
- package/docs/project-knowledge-base/01-system-overview.md +218 -0
- package/docs/project-knowledge-base/02-routes-pages-forms-users.md +346 -0
- package/docs/project-knowledge-base/03-tech-stack-modules-services.md +347 -0
- package/docs/project-knowledge-base/04-data-models-integrations.md +307 -0
- package/docs/project-knowledge-base/05-docs-corpus-inventory.md +193 -0
- package/docs/project-knowledge-base/06-operations-testing-legal-content.md +170 -0
- package/docs/project-knowledge-base/README.md +90 -0
- package/docs/project-profiles/native-update-capacitor-update-platform-project-profile-last-updated-2026-03-16.md +454 -0
- package/docs/project-profiles/native-update-capacitor-update-platform-project-profile-last-updated-2026-03-24.md +66 -0
- package/docs/project-profiles/native-update-capacitor-update-platform-project-profile-last-updated-2026-03-25.md +67 -0
- package/docs/seo-aeo-rules.json +3043 -0
- package/docs/tracking/seo-checklist-tracker.json +333 -0
- package/ios/Plugin/BackgroundUpdate/BackgroundUpdatePlugin.swift +50 -6
- package/ios/Plugin/LiveUpdate/LiveUpdatePlugin.swift +238 -8
- package/ios/Plugin/NativeUpdatePlugin.swift +8 -0
- package/ios/Plugin/Security/SecurityManager.swift +13 -14
- package/package.json +30 -31
- 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
|
|
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(
|
|
432
|
-
prefs.
|
|
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
|
-
|
|
512
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
208
|
-
|
|
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;
|
package/dist/esm/core/config.js
CHANGED
|
@@ -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) {
|