@moonbase.sh/licensing 3.0.0 → 3.1.1

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.
@@ -7,6 +7,13 @@ machine **device id**. Any two SDKs that conform compute the same id on a given
7
7
  license bound by one validates in the other. SDKs adopt the spec independently, so conformance is a
8
8
  property of a given SDK version, not something to assume.
9
9
 
10
+ On iOS and Android that guarantee is narrower, because neither exposes an identifier an unrelated
11
+ app can read. Those platforms use a [scoped identity](#scoped-identity), whose value is tied to the
12
+ app it runs in, so **two apps** on one device compute different ids by design and the `mbd2s_` stamp
13
+ marks it. The algorithm is unchanged: two conforming SDKs embedded in the *same* app still compute
14
+ the same id. What varies is the scope, never the implementation — an SDK that computed something
15
+ different from its peers in the same app would be non-conforming, not scoped.
16
+
10
17
  Implement against this document and prove it against
11
18
  [`fingerprint-vectors.json`](./fingerprint-vectors.json), the machine-readable conformance suite
12
19
  shipped alongside it. If an SDK disagrees with this spec, the SDK is the bug. If this spec
@@ -21,8 +28,11 @@ divergence by defining a byte-exact, deterministic algorithm.
21
28
 
22
29
  ## Stability contract
23
30
 
24
- The algorithm answers one question: *is this the same machine?* Every parameter choice below must
25
- satisfy this table, and any proposed change must be argued against it.
31
+ The algorithm answers one question: *is this the same machine?* Every hardware-identity parameter
32
+ below must satisfy this table, and any proposed change must be argued against it. The two
33
+ [scoped](#scoped-identity) parameters cannot satisfy it and are governed by a
34
+ [weaker contract](#what-scoped-identity-guarantees) instead — that gap is the whole reason they are
35
+ stamped differently.
26
36
 
27
37
  | Event | The device id must |
28
38
  |---|---|
@@ -69,8 +79,29 @@ device_id = "mbd" + version + source_tag + "_" + digest
69
79
  For this version: `mbd2_` plus 64 hex characters, 69 in total. It uses only RFC 3986 unreserved
70
80
  characters, so it never needs escaping in a URL, JSON body, file name or shell command.
71
81
 
72
- `source_tag` is empty for a normal hardware fingerprint, and `n` for the opt-in host-name fallback
73
- (`mbd2n_`, see [Insufficient identity](#insufficient-identity)).
82
+ `source_tag` records how the identity was obtained, and therefore what the id may be compared to:
83
+
84
+ | Tag | Form | Meaning |
85
+ |---|---|---|
86
+ | *(empty)* | `mbd2_` | Hardware identity. Comparable across every conforming SDK on that machine. |
87
+ | `n` | `mbd2n_` | The opt-in host-name fallback. See [Insufficient identity](#insufficient-identity). |
88
+ | `s` | `mbd2s_` | [Scoped identity](#scoped-identity): stable for the device within one [scope](#what-the-scope-actually-is), and **not** comparable across scopes. |
89
+
90
+ Those are the tags this version **defines**. The grammar an SDK **accepts** is deliberately wider:
91
+
92
+ ```
93
+ ^mbd(\d+)([a-z]*)_([0-9a-f]{64})$
94
+ ```
95
+
96
+ A parser MUST accept a source tag it does not recognise, treating the id as opaque and comparing it
97
+ literally rather than rejecting it. That is what lets a new tag be introduced without a version bump,
98
+ so it must be possible to *parse* `mbd2x_…` while knowing only that `x` is not a tag this SDK
99
+ defines. An SDK that hard-codes the three defined tags into its pattern cannot do that, and will
100
+ report a perfectly valid id from a newer SDK as "not a Moonbase device id".
101
+
102
+ The tag is `[a-z]*`, not a single optional character, so a future two-letter tag needs no version
103
+ bump either. `_` terminates it, and digits cannot appear in it, so the split from `version` is
104
+ unambiguous.
74
105
 
75
106
  ### Why the id is stamped
76
107
 
@@ -160,6 +191,8 @@ Exactly these parameters count as **identifying**, describing the individual mac
160
191
  | `machineId` | Linux |
161
192
  | `systemUuid` | Windows |
162
193
  | `baseboardSerialNumber` | Windows |
194
+ | `identifierForVendor` | iOS ([scoped](#scoped-identity)) |
195
+ | `androidId` | Android ([scoped](#scoped-identity)) |
163
196
  | `deviceName` | the opt-in host-name fallback only |
164
197
 
165
198
  At least one must survive canonicalization, or the result is
@@ -179,6 +212,18 @@ product line. Compared case-insensitively against the canonical value:
179
212
  `not available`, `none`, `unknown`, `invalid`, `n/a`, `0123456789`, `uninitialized`, plus any value
180
213
  that is entirely `0`s or entirely `f`/`F`s (a blank UUID field, a zeroed `machine-id`).
181
214
 
215
+ One of those earns its place on mobile: `unknown` is exactly what Android's `Build.SERIAL` returns
216
+ without a privileged permission, so an SDK that reaches for it lands on a fleet-wide constant.
217
+
218
+ **Per-parameter rejections.** A constant that belongs to *one* platform's identifier is rejected for
219
+ that parameter only, never added to the list above. Widening it would change the device id of a
220
+ machine that happens to report the same string as some unrelated field, and any change that alters
221
+ the output for an unchanged machine requires a version bump. Currently there is one:
222
+
223
+ | Parameter | Also rejected | Why |
224
+ |---|---|---|
225
+ | `androidId` | `9774d56d682e549c` | A real `ANDROID_ID` shared by a large batch of 2010-era devices whose `ro.serialno` was unset, seeding the generator identically on every unit. It is valid hex, so the format rule cannot catch it. |
226
+
182
227
  This applies to **identifying parameters only**. A descriptive field reading `Default string` is
183
228
  still a fair description of the model and stays in the material. A serial number reading it is not a
184
229
  serial number.
@@ -188,12 +233,36 @@ serial number.
188
233
  | OS family | Tag |
189
234
  |---|---|
190
235
  | macOS | `mac` |
236
+ | iOS, iPadOS, tvOS, watchOS, visionOS | `ios` |
191
237
  | Windows | `windows` |
192
238
  | Linux | `linux` |
193
239
  | Android | `android` |
194
240
  | FreeBSD / OpenBSD / NetBSD | `bsd` |
195
241
  | anything else | `unknown` |
196
242
 
243
+ Every Apple platform other than macOS maps to `ios`, because they all offer the same single
244
+ identifier and nothing else (watchOS via `WKInterfaceDevice`, the rest via `UIDevice`). Giving them
245
+ one tag is what keeps two SDKs from disagreeing: the tag is hashed into the material, so an SDK that
246
+ mapped tvOS to `unknown` while another mapped it to `ios` would compute different ids on one device.
247
+
248
+ > **The tag follows the OS the process is running on, not the SDK it was built against.** One Apple
249
+ > binary can run in three ways, and the obvious tests (`#if targetEnvironment(macCatalyst)`,
250
+ > `#if os(iOS)`, `UIDevice.systemName`) all get it wrong — a Mac Catalyst build compiles with
251
+ > `os(iOS)` true and reports `systemName` as `iPadOS` while running on macOS. Use the runtime pair:
252
+ >
253
+ > | `isMacCatalystApp` | `isiOSAppOnMac` | Running as | Tag |
254
+ > |---|---|---|---|
255
+ > | `false` | `false` | a real iPhone / iPad | `ios` |
256
+ > | `true` | `false` | Mac Catalyst | `mac` |
257
+ > | `true` | `true` | an iOS app on Apple silicon | `ios` |
258
+ >
259
+ > Mac Catalyst is the case that matters: it can read **both** `identifierForVendor` and IOKit
260
+ > `IOPlatformUUID` (the macOS App Sandbox does not deny IOKit property reads), so without a rule two
261
+ > SDKs on one Mac would disagree about which one to use. Hardware identity wins, per
262
+ > [Scoped identity](#scoped-identity). An unmodified iOS app on Apple silicon cannot reach IOKit, so
263
+ > it stays on the scoped path and its id is not comparable with the Catalyst one — which the `mbd2s_`
264
+ > stamp already says.
265
+
197
266
  ## Identity parameters per platform
198
267
 
199
268
  Parameters **must** appear in the order listed. Reads are best-effort: a missing or unreadable
@@ -295,11 +364,115 @@ SMBIOS structure walking:
295
364
  - An all-`00` or all-`FF` `systemUuid` means "not set" and is treated as **absent**, so fleets of VMs
296
365
  with unset UUIDs cannot collide.
297
366
 
298
- ### Android (`android`), BSD (`bsd`), other (`unknown`)
367
+ ### iOS (`ios`) and Android (`android`) — scoped
368
+
369
+ Both platforms deliberately removed every device identifier that unrelated applications can read.
370
+ An SDK on them MAY emit a [scoped identity](#scoped-identity); it has nothing else to offer.
371
+
372
+ | Platform | Order | Name | Identifying | Source |
373
+ |---|---|---|---|---|
374
+ | iOS | 1 | `identifierForVendor` | ✅ | `[[UIDevice currentDevice] identifierForVendor].UUIDString`, with all `-` removed and **uppercased**, matching `ioPlatformUuid`. On watchOS, `[[WKInterfaceDevice currentDevice] identifierForVendor]` |
375
+ | Android | 1 | `androidId` | ✅ | `Settings.Secure.getString(contentResolver, ANDROID_ID)`, lowercased. Must match `^[0-9a-f]{1,16}$` once canonicalized, or it is treated as **absent** |
376
+
377
+ > The Android value must come from `Settings.Secure.getString`. Reading the static field
378
+ > `Settings.Secure.ANDROID_ID` yields the string constant `"android_id"`, which is the *key name* and
379
+ > is identical on every device. An SDK that hashes it gives its entire Android install base one
380
+ > device id, so a single activation unlocks every device. This is not hypothetical: JUCE's
381
+ > `SystemStats::getUniqueDeviceID()` reads the static field via `GetStaticObjectField`, never
382
+ > touching a `ContentResolver`, and still does so in 9.0.0 — so every JUCE Android app returns the
383
+ > same value. Its `jassert` that the result is non-empty never fires, because the hash of a constant
384
+ > is not empty. The defect is silent.
385
+
386
+ The `^[0-9a-f]{1,16}$` rule is what makes that mistake *mechanically* impossible rather than merely
387
+ documented: `"android_id"` is not hex, so it never reaches the material. The bound is `1,16` and not
388
+ `16` because AOSP before 8.0 generated the value with `Long.toHexString`, which drops leading zeros —
389
+ a strict 16 would reject legitimate ids on roughly one in sixteen pre-Oreo devices.
390
+
391
+ Either value **may be absent**, and then resolves to
392
+ [insufficient identity](#insufficient-identity) rather than to a constant. Apple gives "after the
393
+ device has been restarted but before the user has unlocked it" as *an example* of when
394
+ `identifierForVendor` is `nil`, not an exhaustive list; on Android the value is generated lazily and
395
+ `getString` can return null. Treat absence as normal and retry later rather than assuming a cause.
396
+
397
+ ### BSD (`bsd`), other (`unknown`)
299
398
 
300
399
  No identity parameters are defined. These platforms always resolve to
301
400
  [insufficient identity](#insufficient-identity).
302
401
 
402
+ ## Scoped identity
403
+
404
+ A **scoped** device id is stable for a given device within one *scope*, and carries no meaning
405
+ outside it. It exists because some platforms provide nothing better. The unscoped alternatives are
406
+ gone: iOS has not exposed a hardware serial since iOS 7, Android `Build.SERIAL` returns `unknown`
407
+ without a privileged permission from Android 10, IMEI requires `READ_PRIVILEGED_PHONE_STATE`, and
408
+ MAC addresses are randomised.
409
+
410
+ ### What the scope actually is
411
+
412
+ "One publisher" is a useful shorthand and a poor rule, because neither platform scopes by publisher.
413
+ Be precise, because the difference is observable:
414
+
415
+ | Platform | Scope key |
416
+ |---|---|
417
+ | iOS | The **vendor**: determined by App Store data, and for apps installed any other way, every component of the reverse-DNS bundle id *except the last*. Not the Team ID. |
418
+ | Android, API 26+ | The **app signing key**, per OS user, per device. |
419
+ | Android, before API 26 | The **device and OS user** only. Every app on the device reads the same value. |
420
+
421
+ So `com.example.editor` and `com.example.player` share an iOS scope, while the same publisher's two
422
+ Android apps signed with different keys do **not** share an Android one on API 26 or later. An SDK
423
+ must never assume that "same publisher" means "same scope".
424
+
425
+ > Per-signing-key scoping arrived in Android 8.0. Older devices are still in scope for this spec —
426
+ > the `^[0-9a-f]{1,16}$` rule below deliberately accepts the shorter ids they generate — and on them
427
+ > `ANDROID_ID` is a single per-device value that every installed app can read. That makes the scope
428
+ > *wider* than the table's first Android row, never narrower, so treating those ids as scoped is
429
+ > conservative rather than unsound: the rules below forbid correlating them, which is still correct
430
+ > when they happen to be correlatable. It does mean two unrelated apps on one pre-Oreo device compute
431
+ > the **same** scoped id, so a server must not infer distinct devices from distinct ids, nor one
432
+ > device from one id.
433
+
434
+ ### What scoped identity guarantees
435
+
436
+ Scoped ids are stamped `mbd2s_` so the limitation travels with the value.
437
+
438
+ **The stamp follows the platform, not the call site.** Any id built from the identity parameters of a
439
+ scoped platform is scoped, whatever collected them: an SDK's own reader, a host bridging
440
+ `identifierForVendor` through platform API, an embedder assembling the material itself. Derive the
441
+ source from the platform tag rather than letting a caller pass it, because the one thing that cannot
442
+ happen is a scoped value going out stamped `mbd2_`. That would tell the server it is a hardware
443
+ fingerprint comparable across every app on the device, and the server would then be entitled to
444
+ correlate exactly the ids the rules below forbid correlating.
445
+
446
+ Those rules are what the stamp promises:
447
+
448
+ - Two scoped ids from **different scopes are not comparable at all**. Equal values do not imply the
449
+ same device, and different values do not imply different devices. A validator, a server and an
450
+ analytics pipeline must all refuse to correlate them.
451
+ - A scoped id and an unscoped one are likewise never comparable, so a machine that could produce
452
+ both must not be given a scoped id. See the Mac Catalyst rule under [Platform tags](#platform-tags).
453
+ - Everything else is unchanged: same canonicalization, same material grammar, same digest.
454
+
455
+ Within one scope the [stability contract](#stability-contract) holds for every hardware event in it —
456
+ renames, network changes, OS upgrades. But scoped values also move for reasons no hardware
457
+ identifier does, and this table, not that one, is what `mbd2s_` promises:
458
+
459
+ | Event | The device id may |
460
+ |---|---|
461
+ | App reinstalled, iOS, at least one other app from the vendor still installed | **not** change |
462
+ | App reinstalled, Android, same signing key | **not** change |
463
+ | **Every** app from that vendor deleted, then one reinstalled (iOS) | change |
464
+ | Installed by Xcode or ad-hoc distribution rather than the App Store (iOS) | change |
465
+ | App signing key rotated between uninstall and reinstall (Android, API 26+) | change |
466
+ | Device factory reset | change |
467
+ | A different OS user on the same device (Android) | change |
468
+ | App transferred to another App Store team (iOS) | change |
469
+
470
+ Every "change" row costs the user a re-activation. That is the price of the platform, not a defect
471
+ to be engineered around — the only way to avoid it is an identifier neither platform offers.
472
+
473
+ Scoped identity is a floor, not a preference. An SDK MUST use hardware identity where the platform
474
+ provides it, and MAY use a scoped identity only where it does not.
475
+
303
476
  ## Insufficient identity
304
477
 
305
478
  An SDK **must** raise an error when no parameter survives canonicalization, or when none of the
@@ -316,6 +489,19 @@ parameters. The material is then the single parameter `deviceName=<host name>`,
316
489
  be stamped `mbd2n_` so the weaker binding is visible to the server and to support. If the host name
317
490
  is empty too, that is still insufficient identity.
318
491
 
492
+ > **The host-name fallback MUST NOT be offered on `ios` or `android`.** On those platforms the host
493
+ > name is not weak identity, it is not identity at all: since iOS 17 `gethostname()` and
494
+ > `utsname.nodename` return the literal `localhost` on every device, and since iOS 16
495
+ > `UIDevice.name` returns the model name — `"iPhone"` — regardless of which SDK the app was built
496
+ > against. The entitlement that restores the user-assigned name is granted only to apps that do not
497
+ > use it for fingerprinting, so it is closed to licensing by policy as well as by API.
498
+ >
499
+ > An iOS SDK that fell through to this fallback when `identifierForVendor` was momentarily absent
500
+ > would hand its **entire install base one device id**, and a single activation would unlock every
501
+ > device — exactly the catastrophe this section exists to prevent, reached by following the section
502
+ > above it. On those platforms the ladder is [scoped identity](#scoped-identity), then insufficient
503
+ > identity, and nothing else. Absence is transient: raise the error and retry later.
504
+
319
505
  ## Device name
320
506
 
321
507
  A human-readable label sent alongside the device id at activation. It is **not** part of the
@@ -325,8 +511,15 @@ license.
325
511
  | Platform | Source |
326
512
  |---|---|
327
513
  | macOS | host name, with a trailing `.local` removed (case-insensitive) |
514
+ | iOS | `UIDevice.name` (the model name on iOS 16+), or the empty string |
515
+ | Android | `Settings.Global.DEVICE_NAME`, falling back to `Build.MODEL`, or the empty string |
328
516
  | other | host name |
329
517
 
518
+ On iOS and Android this label is close to worthless for telling two devices apart — it is the model
519
+ name on most modern devices. That is tolerable *because it is only a label*: it never enters the
520
+ material on those platforms, since the host-name fallback is forbidden there. An empty value is fine;
521
+ the server treats the label as decoration, not identity.
522
+
330
523
  ## Worked examples
331
524
 
332
525
  Reproduced by [`fingerprint-vectors.json`](./fingerprint-vectors.json), which contains these and
@@ -368,6 +561,24 @@ baseboardSerialNumber=BSN-42
368
561
  ```
369
562
  → `mbd2_fadd75457e44f669e9865caff122b4706a4501089ac9e73b8735139bf57676ad`
370
563
 
564
+ **iOS** — scoped, note the `s`:
565
+
566
+ ```
567
+ moonbase:fingerprint:v2
568
+ platform=ios
569
+ identifierForVendor=0123456789ABCDEF0123456789ABCDEF
570
+ ```
571
+ → `mbd2s_298ced47f8d983939db1d5fce6d4b4f2f8766aa19e3e17536fcd1604a81febf1`
572
+
573
+ **Android** — also scoped:
574
+
575
+ ```
576
+ moonbase:fingerprint:v2
577
+ platform=android
578
+ androidId=a1b2c3d4e5f60718
579
+ ```
580
+ → `mbd2s_ca988ecf5c529964bfaa80734da3dbe070dd41881aa43f8c472f3a5d512b4eff`
581
+
371
582
  **Opt-in host-name fallback** (note the `n`):
372
583
 
373
584
  ```
@@ -380,7 +591,11 @@ deviceName=PC-1
380
591
  ## Conformance checklist
381
592
 
382
593
  Run [`fingerprint-vectors.json`](./fingerprint-vectors.json) in your SDK's test suite. It covers
383
- every item below; this list is what to look at when a vector fails.
594
+ every item in the first list; that list is what to look at when a vector fails. The second list is
595
+ about *behaviour around* the id rather than its computation, so no vector can settle it — review it
596
+ by hand.
597
+
598
+ **Covered by the vectors:**
384
599
 
385
600
  - [ ] Material prefix is exactly `moonbase:fingerprint:v2`.
386
601
  - [ ] Lines are **joined** with a single LF, and the material has **no trailing newline**.
@@ -398,9 +613,27 @@ every item below; this list is what to look at when a vector fails.
398
613
  - [ ] Windows takes only the first type-1 and first type-2 structure, ignores type 4, and bounds
399
614
  every field read by the structure `length`.
400
615
  - [ ] `systemUuid` is uppercase hex, no hyphens, no byte swap; all-`00`/all-`FF` is absent.
616
+ - [ ] `androidId` comes from `Settings.Secure.getString` and matches `^[0-9a-f]{1,16}$`; the literal
617
+ `"android_id"` never reaches the material.
401
618
  - [ ] Digest is SHA-256 over UTF-8 material, output as 64 lowercase hex characters.
402
- - [ ] The emitted device id is stamped `mbd2_` (or `mbd2n_` for the opt-in fallback).
403
- - [ ] A version difference is surfaced without claiming the license came from this machine.
619
+ - [ ] The emitted device id is stamped `mbd2_` (`mbd2n_` for the opt-in fallback, `mbd2s_` for a
620
+ scoped identity).
621
+ - [ ] A source tag the SDK does not define still **parses**, so the id can be compared literally
622
+ rather than rejected as "not a Moonbase id". A tag that happens to name a built-in of the
623
+ implementation language resolves the same way as any other unknown tag.
624
+ - [ ] An id built from a scoped platform's parameters is stamped `mbd2s_` no matter which code path
625
+ collected them.
626
+
627
+ **Review by hand:**
628
+
629
+ - [ ] The platform tag follows the OS the process runs on. A Mac Catalyst build uses hardware
630
+ identity, not the scoped path.
631
+ - [ ] The host-name fallback is not offered on `ios` or `android`.
632
+ - [ ] A scoped id is never compared against one from another [scope](#what-the-scope-actually-is) —
633
+ in the SDK, on the server, and in analytics. Note that the last two live outside this
634
+ repository, so the vectors could not check them even in principle.
635
+ - [ ] A version or source-tag difference is surfaced without claiming the license came from this
636
+ machine.
404
637
 
405
638
  ## Versioning
406
639
 
package/README.md CHANGED
@@ -59,19 +59,67 @@ console.log('License stored locally')
59
59
 
60
60
  ## Validate on startup
61
61
 
62
- ```ts
63
- const localLicense = await licensing.store.loadLocalLicense()
64
-
65
- if (localLicense) {
66
- // Validate token signature + device binding locally
67
- const locallyValidated = await licensing.validator.validateLicense(localLicense.token)
62
+ Only delete the stored license when Moonbase says the license is gone. Deleting it on any error makes
63
+ the user re-activate every time a check fails for a reason that has nothing to do with the license,
64
+ such as opening the app offline.
68
65
 
69
- // Re-validate against Moonbase (recommended for online activations)
70
- const refreshed = await licensing.client.validateLicense(locallyValidated)
71
- await licensing.store.storeLocalLicense(refreshed)
66
+ ```ts
67
+ import type { License } from '@moonbase.sh/licensing'
68
+ import { ActivationMethod, ErrorType, MoonbaseError } from '@moonbase.sh/licensing'
69
+
70
+ // Moonbase says the license can no longer be used on this device. Anything
71
+ // else, an ErrorType.ApiError or a network failure (which is not a
72
+ // MoonbaseError), only means Moonbase could not be reached right now.
73
+ const licenseGone = new Set<ErrorType | undefined>([
74
+ ErrorType.LicenseRevoked,
75
+ ErrorType.LicenseActivationRevoked,
76
+ ErrorType.LicenseExpired,
77
+ ErrorType.LicenseInvalid,
78
+ ErrorType.LicenseDeviceMismatch,
79
+ ])
80
+
81
+ async function licenseOnStartup(): Promise<License | null> {
82
+ const localLicense = await licensing.store.loadLocalLicense()
83
+ if (!localLicense)
84
+ return null
85
+
86
+ if (localLicense.activationMethod === ActivationMethod.Online) {
87
+ try {
88
+ // Ask Moonbase first: it is how a revocation reaches the app, and how a
89
+ // token whose expiry has passed is refreshed after a renewal
90
+ const refreshed = await licensing.client.validateLicense(localLicense)
91
+ await licensing.store.storeLocalLicense(refreshed)
92
+ return refreshed
93
+ }
94
+ catch (err) {
95
+ if (err instanceof MoonbaseError && licenseGone.has(err.type)) {
96
+ await licensing.store.deleteLocalLicense()
97
+ return null // show your activation flow
98
+ }
99
+ // Moonbase could not be reached, so check the license locally instead
100
+ }
101
+ }
102
+
103
+ try {
104
+ // Signature, device binding and expiry, which needs no network
105
+ return await licensing.validator.validateLicense(localLicense.token)
106
+ }
107
+ catch (err) {
108
+ // Keep the stored token: only Moonbase can say the license is gone, and the
109
+ // next launch that reaches it can still refresh a token that has expired
110
+ if (err instanceof MoonbaseError)
111
+ return null // show your activation flow
112
+ throw err
113
+ }
72
114
  }
73
115
  ```
74
116
 
117
+ A revoked license reports `ErrorType.LicenseRevoked`; a revoked device, whose license may still be
118
+ active, reports `ErrorType.LicenseActivationRevoked`. To limit how long an app keeps running without
119
+ reaching Moonbase, compare `license.validatedAt` with a limit of your choosing before returning the
120
+ locally validated license. For `ErrorType.LicenseDeviceMismatch`, see [Device fingerprint](#device-fingerprint)
121
+ first: after an SDK update, it can mean the same machine under an older fingerprint.
122
+
75
123
  ## Metadata, platform, and app version
76
124
 
77
125
  The SDK can report analytics with every activation, trial request, and validation. These are configured once on the `MoonbaseLicensing` config and sent automatically; there's no per-call parameter.
@@ -116,7 +164,9 @@ cross-SDK **[device fingerprint spec](./FINGERPRINT_SPEC.md)** (`moonbase:finger
116
164
  SHA-256 of stable native hardware identifiers, stamped with the spec version.
117
165
 
118
166
  ```
119
- mbd2_9f3c… // 'mbd' + version + '_' + 64 lowercase hex characters
167
+ mbd2_9f3c… // 'mbd' + version + source tag + '_' + 64 lowercase hex characters
168
+ mbd2n_9f3c… // 'n': the opt-in host-name fallback (see below)
169
+ mbd2s_9f3c… // 's': an app-scoped id, which only the mobile SDKs produce
120
170
  ```
121
171
 
122
172
  Sources are SMBIOS on Windows, `IOPlatformUUID` on macOS, and `machine-id` plus world-readable DMI
@@ -125,6 +175,13 @@ passes the shipped [`fingerprint-vectors.json`](./fingerprint-vectors.json) comp
125
175
  given machine, so a license activated by one validates in the others. Adoption is per-SDK, so check
126
176
  the version of whichever SDK you are pairing with before relying on it.
127
177
 
178
+ This package **parses** all three forms — including a tag introduced by a newer SDK, which it
179
+ compares literally rather than rejecting — but its built-in readers only ever **produce** `mbd2_` or
180
+ `mbd2n_`. Scoped ids come from iOS and Android, where the OS exposes no identifier an unrelated app
181
+ can read; collecting them needs platform API no Node.js process can call, so that is the C++/.NET
182
+ SDKs' job. A scoped id is stable only within one app scope and must never be compared with one from
183
+ another, which is exactly what the `s` tag exists to announce.
184
+
128
185
  > Both files ship inside the package. If the links above do not resolve where you are reading this,
129
186
  > find them in `node_modules/@moonbase.sh/licensing/`.
130
187
 
@@ -157,7 +214,8 @@ catch (err) {
157
214
  The resolver throws `InsufficientDeviceIdentityError` rather than falling back to something weak, in
158
215
  two cases:
159
216
 
160
- - **Nothing readable.** A sandboxed process, or a platform with no defined parameters (Android, BSD).
217
+ - **Nothing readable.** A sandboxed process; a platform the spec defines no parameters for (BSD, and
218
+ anything unrecognised); or Android, whose parameter this package cannot reach from Node.
161
219
  - **Only model-level values readable.** Vendor, product and board names are byte-identical across
162
220
  every unit of a product line, so fingerprinting them would let those machines validate one
163
221
  another's licenses. In practice: a Linux install with no `machine-id`, or a machine whose SMBIOS