@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 +21 -0
- package/README.md +406 -0
- package/dist/index.cjs +1512 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +985 -0
- package/dist/index.d.ts +985 -0
- package/dist/index.js +1462 -0
- package/dist/index.js.map +1 -0
- package/package.json +32 -0
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).
|