@antondziuin/talos-sdk 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Talos
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,406 @@
1
+ # @antondziuin/talos-sdk (Node.js)
2
+
3
+ Online-first client SDK for the Talos licensing platform. Node ≥18, **zero
4
+ runtime dependencies** (`node:crypto` Ed25519, built-in `fetch`), dual ESM + CJS.
5
+
6
+ ```sh
7
+ npm install @antondziuin/talos-sdk
8
+ ```
9
+
10
+ MIT-licensed, from `0.1.0` onward. The platform it talks to is not — the split is
11
+ deliberate: a library you install into your own application should not be
12
+ something your legal review has to read.
13
+
14
+ ## Quickstart
15
+
16
+ ```ts
17
+ import { TalosClient } from "@antondziuin/talos-sdk";
18
+
19
+ const talos = new TalosClient({
20
+ productToken: "tpt_…", // from the portal's Integration page
21
+ anchorPublicKey: "base64-anchor-key", // pin THIS — the rotation-safe root
22
+ serverUrl: "https://api.talos.dev",
23
+ appVersion: "1.4.2",
24
+ });
25
+
26
+ // The activation is stored on this machine, so the second launch onwards has
27
+ // nothing to ask the user for.
28
+ const state = talos.isActivated
29
+ ? await talos.validate() // machine-bound signed decision
30
+ : await talos.activate(userEnteredLicenseKey);
31
+ if (!state.isValid) promptForKey();
32
+
33
+ if (talos.isFeatureEnabled("pdf-export")) enablePdfMenu();
34
+ const seats = Number(talos.current?.getEntitlement("seats") ?? 1);
35
+ ```
36
+
37
+ ## Supplying your own `fetch`
38
+
39
+ `options.fetch` replaces the global one for **every** network call, artifact
40
+ downloads included — so a corporate proxy, a custom CA bundle, request logging
41
+ or a test double applies uniformly. It must return `{ status, text(),
42
+ arrayBuffer() }`; `headers` and `body` are optional, and when the implementation
43
+ provides a streaming `body` the artifact download meters it against the signed
44
+ manifest's size instead of buffering first.
45
+
46
+ ## Licensing calls have a deadline
47
+
48
+ `options.httpTimeoutMs` (default 10000, `0` disables) bounds every licensing
49
+ call. Node's `fetch` has none of its own, so without it a server that accepts
50
+ the connection and then says nothing leaves `activate()` or `validate()` pending
51
+ forever — which in a desktop application is a splash screen that never goes
52
+ away, on the one code path a user cannot skip.
53
+
54
+ A timeout surfaces as a `TalosError`, deliberately: `validate()` treats network
55
+ failure as the grace-period case, and an abort arriving as something else would
56
+ stop the application instead of letting it keep running offline.
57
+
58
+ It does **not** cap `downloadUpdate`. A total-duration limit on a
59
+ multi-megabyte artifact is a size limit disguised as a safeguard — the same ten
60
+ seconds that is generous for a licensing call fails every download on a slow
61
+ connection. That one is bounded by the signed manifest's `size`.
62
+
63
+ An injected `fetch` receives the `signal` and is expected to honour it; one that
64
+ ignores it has no timeout.
65
+
66
+ ## The licence key is not kept
67
+
68
+ After activation the SDK holds no licence key — not in memory, not in the state
69
+ file. The server resolves the licence from the per-machine activation secret,
70
+ which is what these calls authenticate with anyway, so the key was only a lookup
71
+ value travelling beside the credential.
72
+
73
+ That matters because the state file lives on a disk the user does not solely
74
+ control. It is encrypted, but with material any process on that machine can
75
+ read; a plaintext licence key in it is a credential-shaped file, and a licence
76
+ key works anywhere. A machine-bound secret does not.
77
+
78
+ What is kept is a SHA-256 of the key, for one thing only: activating twice with
79
+ the same key validates instead of spending a second seat. It is accepted by
80
+ nothing — the server matches an HMAC under a pepper no client has seen.
81
+
82
+ A state file written by an older version is upgraded on first read, so nobody
83
+ re-activates to get this. And `license_key` is still accepted by every route, so
84
+ an application shipped against an older SDK keeps working unchanged.
85
+
86
+ ## The machine binding is persistent
87
+
88
+ `activate` binds this machine and the SDK writes that binding to a per-product
89
+ file under the OS user-data directory. A `TalosClient` restores it in its
90
+ constructor, so `talos.isActivated` answers the startup question — prompt for a
91
+ key, or go straight to `validate()` — and the user enters their key once, not
92
+ once per launch.
93
+
94
+ That file is `%LOCALAPPDATA%\Talos\<slot>\state.bin` on Windows,
95
+ `~/Library/Application Support/Talos/<slot>/state.bin` on macOS,
96
+ `$XDG_DATA_HOME/talos/<slot>/state.bin` (or `~/.local/share/...`) on Linux —
97
+ where `<slot>` is a hash of the product token, and the same three paths the C#
98
+ SDK uses, so one product cannot end up with two bindings in two places.
99
+
100
+ `activate()` is safe to call on every start: when a binding for the same key is
101
+ already held it **re-validates** instead of re-activating. That is not just an
102
+ optimisation. Re-activating rotates the activation secret, so a second copy of
103
+ your app on the same machine would knock the first one out; and `activate` is
104
+ the one client route refused while the vendor's own Talos account is suspended,
105
+ so re-activating on every launch would take *your* paying users offline over
106
+ *your* billing dispute. It falls back to a real activation when the stored
107
+ binding is genuinely gone — the seat was released from the portal, or the secret
108
+ was rotated elsewhere.
109
+
110
+ `deactivate()` forgets the binding on disk as well as in memory.
111
+
112
+ Set `statePath` to choose the file, or `persistState: false` for a process that
113
+ should not leave a licence on disk (a test, a short-lived worker) — at the cost
114
+ of everything in the paragraph above.
115
+
116
+ The file is encrypted with a key derived from a stable machine identifier, but
117
+ treat that as tamper-evidence and resistance to casual copying, **not** secrecy:
118
+ whoever owns the machine can derive the same key. What actually stops a copied
119
+ state file is the server — it resolves a machine by fingerprint and demands the
120
+ activation secret, so the file does not work on a different computer. A corrupt
121
+ or unreadable file is treated as "not activated yet", never as an error: a
122
+ damaged cache costs a re-activation, not a failed launch.
123
+
124
+ ## Offline and grace
125
+
126
+ `validate()` is online-first and offline-tolerant. When the server answers, that
127
+ answer wins and is cached. When it cannot be reached, the last verified verdict
128
+ is honoured until the policy's `offline_grace_hours` runs out, so a laptop on a
129
+ plane keeps working instead of losing the licensed application the moment the
130
+ network does.
131
+
132
+ ```ts
133
+ const state = await talos.validate();
134
+ if (state.isOffline) showBanner("Working offline — checked in " + ago(state.graceUntil));
135
+ switch (state.decision) {
136
+ case "valid": break;
137
+ case "grace_expired": requireConnection(); break; // offline too long
138
+ case "clock_tampered": requireConnection(); break; // the clock moved back
139
+ default: showLicenceProblem(state.decision);
140
+ }
141
+ ```
142
+
143
+ What is *not* cached matters as much. **A status code from the server is an
144
+ answer** — "revoked", "no such machine", "tenant suspended" — and is never
145
+ masked by the cache; falling back there would turn every negative verdict into
146
+ hours of grace, which is the opposite of its purpose. Only the absence of an
147
+ answer falls back: a dead network, or a reply that fails signature verification
148
+ (a garbled or forged response tells you nothing, so it is treated as
149
+ unreachable — never as valid).
150
+
151
+ Four things bound the cached verdict, and each closes a specific hole:
152
+
153
+ - it is **re-verified under the pinned key** on every use, so editing the state
154
+ file achieves nothing;
155
+ - its `fph` claim must be *this* machine, so copying the file to a second
156
+ computer and pulling the network cable does not licence it;
157
+ - the licence's own expiry is re-checked, so a cached "valid" cannot outlive
158
+ the subscription it was issued under;
159
+ - and a **persistent server-time high-water mark** — the highest signed server
160
+ clock ever seen — is compared against the local clock. Without it, winding the
161
+ system clock back would renew the grace window forever. A monotonic timer is
162
+ not a substitute: it resets on reboot and on a VM snapshot restore, which is
163
+ exactly the case that matters. A clock behind the mark reports
164
+ `clock_tampered`, which is deliberately distinct from `expired` — nothing has
165
+ run out, the machine is lying about the time.
166
+
167
+ The anchor-signed keyset is cached alongside it, so the whole chain verifies
168
+ offline against a root the network cannot influence: the anchor is compiled into
169
+ your app, the keyset is signed by it, and the validation token is signed by a key
170
+ it lists.
171
+
172
+ Every server response is a Talos-compact token signed by the product key and
173
+ verified against the pinned key, so a fake/MITM server cannot forge an acceptable
174
+ answer. The SDK ignores any `kid` and enforces `typ`/`aud`/nonce/`exp`/`nbf`. In
175
+ Electron, run this in the **main process** and bridge to the renderer over IPC.
176
+
177
+ **Key rotation (anti-lock-in).** Pin `anchorPublicKey` — the product's immutable
178
+ root. The SDK fetches the anchor-signed *keyset* (delivered with activation, or
179
+ `await talos.refreshKeyset()`) and learns the current license/release keys from
180
+ it. When you rotate a signing key in the portal, deployed apps keep working with
181
+ no reconfiguration. (`publicKey`/`releasePublicKey` are still accepted as direct
182
+ pins, but they break on the first rotation — prefer the anchor.)
183
+
184
+ Anti-tamper honesty: JS is patchable — gate premium value on server-returned
185
+ *data* (entitlements/config), not client booleans.
186
+
187
+ ## Heartbeats, monitoring, and cadence
188
+
189
+ ```ts
190
+ talos.startCheckIns({
191
+ onState: (s) => updateUi(s),
192
+ onError: (e) => console.warn("check-in failed", e),
193
+ });
194
+ // On shutdown:
195
+ talos.stopCheckIns();
196
+ ```
197
+
198
+ One background loop, driven by the **policy** rather than by a number you pick:
199
+ each tick re-validates once the signed `reval_after` has passed, and otherwise
200
+ sends a heartbeat at the signed `heartbeat_interval_s`. Change the policy in the
201
+ portal and deployed apps follow — which is the point, because that interval is
202
+ both the telemetry rate and the upper bound on how long a revocation takes to
203
+ reach an install. Both values come from the signed claims, never the response
204
+ envelope, so nobody in the middle can tell a fleet to check in less often.
205
+
206
+ Running the loop is also what keeps the offline cache fresh, so an app that
207
+ starts it stays inside its grace window without scheduling anything itself.
208
+
209
+ A heartbeat refreshes this machine's last-seen state, returns the current
210
+ **signed** decision, and feeds the developer portal's monitoring — active
211
+ installs and version adoption. It reports only the app version and OS/arch; no
212
+ IP address is stored. Call `talos.heartbeat()` directly if you would rather own
213
+ the schedule; `talos.heartbeatIntervalSeconds` is the policy's answer once the
214
+ first one has been sent.
215
+
216
+ ## Events: the transitions, not the level
217
+
218
+ ```ts
219
+ talos.on("gracePeriodStarted", (s) => showOfflineBanner(s.graceUntil));
220
+ talos.on("gracePeriodExpired", () => requireConnection());
221
+ talos.on("licenseInvalid", (s) => showLicenceProblem(s.decision));
222
+ talos.on("clockTamperDetected", () => showClockWarning());
223
+ talos.on("updateAvailable", (info) => offerUpdate(info.version));
224
+ ```
225
+
226
+ Four of them are licence transitions, each raised **once** on the way in and not again while nothing
227
+ changes: an application offline for a week gets one `gracePeriodStarted`, not
228
+ one per check-in. `licenseInvalid` covers the server's own verdicts (`expired`,
229
+ `revoked`, `suspended`, `deactivated`, `machine_revoked`, `invalid`) and fires
230
+ again when the *reason* changes, because "your subscription lapsed" and "this
231
+ machine was revoked" are different things to put in front of a user. The two
232
+ grace verdicts are deliberately not folded into it: what ran out there is
233
+ permission to keep believing a cached answer, and reporting it as a licence
234
+ problem sends the user to support instead of to their network.
235
+
236
+ `on` returns a function that unsubscribes. Listeners are called synchronously,
237
+ from every path that adopts a verdict — `activate`, `validate`, `heartbeat` and
238
+ the offline fallback — so a host running its own timer gets the same
239
+ transitions. A listener that throws is ignored: these are raised from inside
240
+ `validate`, whose own catch treats a throw as an unreachable server, and a
241
+ broken banner must not turn a live "revoked" answer into offline grace.
242
+
243
+ There is deliberately no "grace ended" or "licence recovered" event. That is a
244
+ level, and `onState` (or `talos.current`) already carries it on every check-in;
245
+ a second way to learn one fact is a second thing to keep in step.
246
+
247
+ `updateAvailable` is the odd one out: it carries an `UpdateInfo`, not a licence
248
+ state, and it is how an application learns about a release **without asking**.
249
+ Subscribing to it makes each heartbeat name your `updateChannel` (`stable`
250
+ unless you set one), which is what asks the server whether anything is waiting;
251
+ on a yes, the SDK runs a real `checkForUpdate` and hands you what that verified
252
+ — the signed manifest and this install's own anti-downgrade floor, never the
253
+ hint's word. Nothing is asked for and nothing is spent while no listener is
254
+ attached, and each release is announced once however long the user puts off
255
+ installing it.
256
+
257
+ ## When updates are refused
258
+
259
+ A maintenance licence that has run out is **not** a licence that has failed.
260
+ `validate()` still returns `valid` — the customer keeps the version they bought,
261
+ and their application must keep working — and only the update check refuses:
262
+
263
+ ```ts
264
+ try {
265
+ const upd = await talos.checkForUpdate();
266
+ if (upd) offerUpdate(upd);
267
+ } catch (e) {
268
+ if (e instanceof TalosApiError && e.errorCode === "updates_not_entitled") {
269
+ // Not a licensing failure. Say what it is and what fixes it.
270
+ showNotice("Your maintenance period has ended. The app keeps working; renew to get updates.");
271
+ } else {
272
+ throw e;
273
+ }
274
+ }
275
+ ```
276
+
277
+ Both mistakes here cost something. Treating the 403 as a licensing error locks a
278
+ paying customer out of software they own. Treating it as "no update available"
279
+ never tells them their maintenance lapsed, so they find out when they ask why
280
+ they are three versions behind — and nobody renews for a reason they were not
281
+ given.
282
+
283
+ The same code arrives on the artifact download, for the same reason: the gate is
284
+ the licence's `updates` entitlement, checked on both routes.
285
+
286
+ ## Hardening the shipped bundle
287
+
288
+ The renderer, the bundle and the disk all belong to the person running your
289
+ application, so nothing here is a licence check — the checks are on the server,
290
+ and the entitlement is the thing worth gating on (see the portal's
291
+ "Entitlements" page). What client-side hardening buys is time.
292
+
293
+ - **Bundle first**, with esbuild or webpack: one file, no module boundaries, no
294
+ helpfully-named source files to open.
295
+ - **Then `bytenode`**, which compiles that bundle to V8 bytecode — the practical
296
+ option for Node and Electron applications, and the one this SDK's C# sibling
297
+ reaches for with NativeAOT.
298
+ - **Keep the client in the main process** in Electron. The renderer is the part
299
+ an attacker already controls; it should be told a verdict and never hold the
300
+ activation secret (see `examples/electron-main`).
301
+
302
+ Honestly labelled: bytecode can be decompiled and every obfuscator has a
303
+ deobfuscator. These buy an attacker's afternoon, not their ability — which is
304
+ why the server decides seats, expiry and revocation, and why a feature that only
305
+ exists on the server cannot be patched into existence on the client.
306
+
307
+ ## Hardware changes
308
+
309
+ The SDK identifies the machine by a set of component hashes (never the raw
310
+ identifiers — each is HMAC'd with a product-scoped key before it is sent). If
311
+ the policy uses tolerant matching, a machine that replaces a disk or reinstalls
312
+ the OS keeps its seat instead of forcing the user through a re-activation.
313
+
314
+ **Node's reach is limited.** With no native dependencies and no subprocesses,
315
+ this SDK can read a stable machine identity on Linux but not on Windows or
316
+ macOS, so those hosts fall back to exact matching: change a NIC and the user
317
+ re-activates. If your application can do better — an Electron native module, or
318
+ an id you already hold — pass it in and the server will use it:
319
+
320
+ ```ts
321
+ const talos = new TalosClient({
322
+ productToken: "tpt_…",
323
+ anchorPublicKey: "…",
324
+ serverUrl: "https://api.talos.dev",
325
+ fingerprintComponents: { machine_guid: myNativeModule.machineId() },
326
+ });
327
+ ```
328
+
329
+ The value must be stable for the life of the install — one that changes per
330
+ launch reports a hardware change on every launch. See
331
+ [docs/FINGERPRINT.md](../../docs/FINGERPRINT.md) for the recognised ids, the
332
+ per-platform coverage table, and the drift budget that bounds all of this.
333
+
334
+ ## Floating (concurrent) licenses
335
+
336
+ ```ts
337
+ // Policy kind "floating" with max_concurrent_seats = N.
338
+ try {
339
+ const lease = await talos.acquireLease({
340
+ onLeaseLost: (why) => showBanner(`Seat lost: ${why}`),
341
+ });
342
+ console.log(`seat ${lease.seatsInUse}/${lease.maxSeats}`);
343
+ } catch (e) {
344
+ if (e instanceof TalosApiError && e.statusCode === 409) showBusyDialog();
345
+ }
346
+
347
+ // On clean exit:
348
+ await talos.releaseLease();
349
+ ```
350
+
351
+ The server arbitrates seats atomically, so two instances can never both take the
352
+ last one. The seat auto-renews in the background; a crash needs no cleanup —
353
+ the lease simply expires and the seat returns to the pool. Re-acquiring with the
354
+ same `instanceId` renews rather than consuming a second seat.
355
+
356
+ ## Updates
357
+
358
+ ```ts
359
+ const talos = new TalosClient({
360
+ productToken: "tpt_…",
361
+ publicKey: "…", // license key (activation/validation tokens)
362
+ releasePublicKey: "…", // release key (update manifests) — separate custody
363
+ serverUrl: "https://api.talos.dev",
364
+ });
365
+ await talos.activate(licenseKey);
366
+
367
+ const upd = await talos.checkForUpdate();
368
+ if (upd) {
369
+ // `upd` is built from a manifest signed by the pinned release key. Metadata
370
+ // (version, sha256, size, filename) is trusted; the download URL is not.
371
+ const dest = `/tmp/${upd.artifacts[0].filename}`;
372
+ await talos.downloadUpdate(upd, dest, upd.artifacts[0], ({ bytesReceived, totalBytes }) => {
373
+ showProgress(bytesReceived / totalBytes); // optional; called as the bytes arrive
374
+ });
375
+ // Hand `dest` to your installer / electron-updater. Applying is the app's job.
376
+ }
377
+ ```
378
+
379
+ The bytes stream to a `.talos-part` file beside the destination, hashed as they
380
+ arrive, and are renamed into place only once the hash matches — so a
381
+ multi-gigabyte installer is never held in memory, and a download that fails
382
+ verification leaves whatever was at `dest` before rather than a truncated file
383
+ your updater might run. `totalBytes` is the signed manifest's size, never the
384
+ server's `content-length`: a progress bar the download host can drive can be
385
+ parked at 100% while bytes are still arriving.
386
+
387
+ The manifest carries a monotonic `releaseSeq`, and the SDK refuses to surface a
388
+ release at or below the highest one this install has been seen running
389
+ (**anti-downgrade**). It keeps that mark itself, in the state file: when
390
+ `downloadUpdate` fetches a release, the version is remembered, and the next time
391
+ a client is constructed with `appVersion` equal to it the mark moves up. So a
392
+ server that offers an older build — a rollback, a stale replica, or somebody in
393
+ the middle — is answered with `null` and nothing else is needed from you.
394
+
395
+ Pass `currentReleaseSeq` only when you know something the SDK cannot: a build
396
+ installed by a package manager, or a first run after adopting the SDK on a fleet
397
+ that is already updated.
398
+
399
+ Downloaded bytes are verified against the signed manifest's SHA-256, so the
400
+ CDN/mirror serving them is untrusted infrastructure.
401
+
402
+ ## Tests
403
+
404
+ - `npm test` runs the client-side verification tests (no server needed).
405
+ - The cross-language **contract test** (`test/contract.test.ts`) runs the SDK
406
+ against a live Rust server; see [`../csharp/contract.sh`](../csharp/contract.sh).