slybrowser 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/README.md +146 -0
- package/dist/automation.d.ts +15 -0
- package/dist/automation.d.ts.map +1 -0
- package/dist/automation.js +69 -0
- package/dist/automation.js.map +1 -0
- package/dist/browser.d.ts +31 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +203 -0
- package/dist/browser.js.map +1 -0
- package/dist/canonical.d.ts +4 -0
- package/dist/canonical.d.ts.map +1 -0
- package/dist/canonical.js +42 -0
- package/dist/canonical.js.map +1 -0
- package/dist/cdp.d.ts +21 -0
- package/dist/cdp.d.ts.map +1 -0
- package/dist/cdp.js +139 -0
- package/dist/cdp.js.map +1 -0
- package/dist/cli.d.ts +19 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +241 -0
- package/dist/cli.js.map +1 -0
- package/dist/errors.d.ts +21 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +113 -0
- package/dist/errors.js.map +1 -0
- package/dist/humanize.d.ts +23 -0
- package/dist/humanize.d.ts.map +1 -0
- package/dist/humanize.js +247 -0
- package/dist/humanize.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/installer.d.ts +43 -0
- package/dist/installer.d.ts.map +1 -0
- package/dist/installer.js +669 -0
- package/dist/installer.js.map +1 -0
- package/dist/launcher.d.ts +38 -0
- package/dist/launcher.d.ts.map +1 -0
- package/dist/launcher.js +325 -0
- package/dist/launcher.js.map +1 -0
- package/dist/license.d.ts +76 -0
- package/dist/license.d.ts.map +1 -0
- package/dist/license.js +329 -0
- package/dist/license.js.map +1 -0
- package/dist/licensed.d.ts +82 -0
- package/dist/licensed.d.ts.map +1 -0
- package/dist/licensed.js +513 -0
- package/dist/licensed.js.map +1 -0
- package/dist/manifest.d.ts +66 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +292 -0
- package/dist/manifest.js.map +1 -0
- package/dist/service.d.ts +206 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +1271 -0
- package/dist/service.js.map +1 -0
- package/dist/webdriver.d.ts +208 -0
- package/dist/webdriver.d.ts.map +1 -0
- package/dist/webdriver.js +592 -0
- package/dist/webdriver.js.map +1 -0
- package/package.json +78 -0
package/dist/service.js
ADDED
|
@@ -0,0 +1,1271 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createDecipheriv, createHash, createPublicKey, randomUUID, scryptSync, verify as verifySignature } from "node:crypto";
|
|
3
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { gunzipSync } from "node:zlib";
|
|
6
|
+
import { canonicalJson, decodeBase64Url } from "./canonical.js";
|
|
7
|
+
import { LicenseServiceError } from "./errors.js";
|
|
8
|
+
import { LicenseVerifier } from "./license.js";
|
|
9
|
+
import { isSdkCompatible, selectArtifact, verifyReleaseManifest, } from "./manifest.js";
|
|
10
|
+
function fail(code, message, status = 0, details = {}) {
|
|
11
|
+
throw new LicenseServiceError(message, code, status, details);
|
|
12
|
+
}
|
|
13
|
+
const SAFE_ERROR_DETAIL_FIELDS = new Set([
|
|
14
|
+
"state",
|
|
15
|
+
"concurrencyLimit",
|
|
16
|
+
"activeSessions",
|
|
17
|
+
"availableSessions",
|
|
18
|
+
"retryAfterSeconds",
|
|
19
|
+
"action",
|
|
20
|
+
"dimension",
|
|
21
|
+
]);
|
|
22
|
+
function safeRemoteErrorCode(code) {
|
|
23
|
+
return typeof code === "string" && /^[a-z0-9_]{2,96}$/.test(code) ? code : "license_service_error";
|
|
24
|
+
}
|
|
25
|
+
function remoteErrorMessage(status, code) {
|
|
26
|
+
if (code === "license_plan_expired") {
|
|
27
|
+
return "The SlyBrowser paid plan has expired. Renew the plan before starting SlyBrowser.";
|
|
28
|
+
}
|
|
29
|
+
return `License service request failed with HTTP ${status} (${code})`;
|
|
30
|
+
}
|
|
31
|
+
function safeErrorDetails(error) {
|
|
32
|
+
if (!error)
|
|
33
|
+
return {};
|
|
34
|
+
const details = {};
|
|
35
|
+
for (const [key, value] of Object.entries(error)) {
|
|
36
|
+
if (SAFE_ERROR_DETAIL_FIELDS.has(key)) {
|
|
37
|
+
const safeValue = safeErrorDetailValue(key, value);
|
|
38
|
+
if (safeValue !== undefined)
|
|
39
|
+
details[key] = safeValue;
|
|
40
|
+
}
|
|
41
|
+
else if (key === "actions" && Array.isArray(value)) {
|
|
42
|
+
const actions = value.map(safeErrorAction).filter((action) => Object.keys(action).length > 0);
|
|
43
|
+
if (actions.length)
|
|
44
|
+
details.actions = actions;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return details;
|
|
48
|
+
}
|
|
49
|
+
function safeErrorDetailValue(key, value) {
|
|
50
|
+
if (["concurrencyLimit", "activeSessions", "availableSessions", "retryAfterSeconds"].includes(key)) {
|
|
51
|
+
return Number.isSafeInteger(value) ? value : undefined;
|
|
52
|
+
}
|
|
53
|
+
return typeof value === "string" && /^[a-z0-9_:-]{1,96}$/.test(value) ? value : undefined;
|
|
54
|
+
}
|
|
55
|
+
function safeErrorAction(value) {
|
|
56
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
57
|
+
return {};
|
|
58
|
+
const action = value;
|
|
59
|
+
const result = {};
|
|
60
|
+
if (typeof action.type === "string" && /^[a-z0-9_:-]{1,96}$/.test(action.type))
|
|
61
|
+
result.type = action.type;
|
|
62
|
+
if (typeof action.url === "string" && action.url.startsWith("https://slybrowser.com/"))
|
|
63
|
+
result.url = action.url;
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
function parseAuthorization(value, allowInsecureLocalhost = false) {
|
|
67
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
68
|
+
fail("authorization_invalid", "Authorization file must be an object");
|
|
69
|
+
const document = value;
|
|
70
|
+
if (Object.keys(document).sort().join(",") !== "channel,licenseKey,schemaVersion,serviceUrl" ||
|
|
71
|
+
document.schemaVersion !== 1 || document.channel !== "stable" ||
|
|
72
|
+
typeof document.licenseKey !== "string" ||
|
|
73
|
+
!/^sly_live_[0-9a-f-]{36}\.[A-Za-z0-9_-]{40,}$/.test(document.licenseKey) ||
|
|
74
|
+
typeof document.serviceUrl !== "string") {
|
|
75
|
+
fail("authorization_invalid", "Authorization file fields are invalid");
|
|
76
|
+
}
|
|
77
|
+
let url;
|
|
78
|
+
try {
|
|
79
|
+
url = new URL(document.serviceUrl);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
fail("authorization_invalid", "Authorization service URL is invalid");
|
|
83
|
+
}
|
|
84
|
+
const local = new Set(["127.0.0.1", "localhost", "::1"]).has(url.hostname);
|
|
85
|
+
if (url.protocol !== "https:" && !(allowInsecureLocalhost && local && url.protocol === "http:")) {
|
|
86
|
+
fail("authorization_invalid", "Authorization service URL must use HTTPS");
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
schemaVersion: 1,
|
|
90
|
+
serviceUrl: url.toString().replace(/\/$/, ""),
|
|
91
|
+
licenseKey: document.licenseKey,
|
|
92
|
+
channel: "stable",
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const LICENSE_FILE_TOP_LEVEL_FIELDS = new Set([
|
|
96
|
+
"schemaVersion",
|
|
97
|
+
"type",
|
|
98
|
+
"audience",
|
|
99
|
+
"serviceUrl",
|
|
100
|
+
"licenseId",
|
|
101
|
+
"channel",
|
|
102
|
+
"issuedAt",
|
|
103
|
+
"expiresAt",
|
|
104
|
+
"fileId",
|
|
105
|
+
"encryption",
|
|
106
|
+
"ciphertext",
|
|
107
|
+
"tag",
|
|
108
|
+
"signature",
|
|
109
|
+
]);
|
|
110
|
+
const LICENSE_FILE_SECRET_FIELDS = new Set([
|
|
111
|
+
"schemaVersion",
|
|
112
|
+
"type",
|
|
113
|
+
"audience",
|
|
114
|
+
"licenseId",
|
|
115
|
+
"fileId",
|
|
116
|
+
"serviceUrl",
|
|
117
|
+
"channel",
|
|
118
|
+
"licenseKey",
|
|
119
|
+
"secretVersion",
|
|
120
|
+
"createdAt",
|
|
121
|
+
"expiresAt",
|
|
122
|
+
"nonce",
|
|
123
|
+
"scope",
|
|
124
|
+
]);
|
|
125
|
+
const USER_LICENSE_FILE_FORMAT = "slybrowser-license-compact-v1";
|
|
126
|
+
const USER_LICENSE_FILE_FIELDS = new Set(["schemaVersion", "type", "format", "license"]);
|
|
127
|
+
const USER_LICENSE_FILE_MAX_COMPRESSED_BYTES = 64 * 1024;
|
|
128
|
+
const USER_LICENSE_FILE_MAX_DOCUMENT_BYTES = 256 * 1024;
|
|
129
|
+
const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
|
130
|
+
function normalizeServiceUrl(value, allowInsecureLocalhost = false) {
|
|
131
|
+
let url;
|
|
132
|
+
try {
|
|
133
|
+
url = new URL(value);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
fail("authorization_invalid", "Authorization service URL is invalid");
|
|
137
|
+
}
|
|
138
|
+
const local = new Set(["127.0.0.1", "localhost", "::1"]).has(url.hostname);
|
|
139
|
+
if (url.protocol !== "https:" && !(allowInsecureLocalhost && local && url.protocol === "http:")) {
|
|
140
|
+
fail("authorization_invalid", "Authorization service URL must use HTTPS");
|
|
141
|
+
}
|
|
142
|
+
return url.toString().replace(/\/$/, "");
|
|
143
|
+
}
|
|
144
|
+
function requiredText(document, name, maximum = 2048) {
|
|
145
|
+
const value = document[name];
|
|
146
|
+
if (typeof value !== "string" || !value || value.length > maximum || /[\u0000-\u001f]/.test(value)) {
|
|
147
|
+
fail("authorization_invalid", `License file ${name} is invalid`);
|
|
148
|
+
}
|
|
149
|
+
return value;
|
|
150
|
+
}
|
|
151
|
+
function requiredInteger(document, name) {
|
|
152
|
+
const value = document[name];
|
|
153
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value)) {
|
|
154
|
+
fail("authorization_invalid", `License file ${name} is invalid`);
|
|
155
|
+
}
|
|
156
|
+
return value;
|
|
157
|
+
}
|
|
158
|
+
function requiredObject(value) {
|
|
159
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
160
|
+
fail("authorization_invalid", "License file fields are invalid");
|
|
161
|
+
}
|
|
162
|
+
return value;
|
|
163
|
+
}
|
|
164
|
+
function unwrapUserLicenseFile(value) {
|
|
165
|
+
const wrapper = requiredObject(value);
|
|
166
|
+
if (wrapper.schemaVersion !== 1 ||
|
|
167
|
+
wrapper.type !== "slybrowser-license" ||
|
|
168
|
+
wrapper.format !== USER_LICENSE_FILE_FORMAT) {
|
|
169
|
+
return value;
|
|
170
|
+
}
|
|
171
|
+
if (Object.keys(wrapper).some((name) => !USER_LICENSE_FILE_FIELDS.has(name)) ||
|
|
172
|
+
Object.keys(wrapper).length !== USER_LICENSE_FILE_FIELDS.size) {
|
|
173
|
+
fail("authorization_invalid", "License file wrapper fields are invalid");
|
|
174
|
+
}
|
|
175
|
+
let serialized;
|
|
176
|
+
try {
|
|
177
|
+
serialized = gunzipSync(decodeBase64Url(requiredText(wrapper, "license", USER_LICENSE_FILE_MAX_COMPRESSED_BYTES * 2), USER_LICENSE_FILE_MAX_COMPRESSED_BYTES), { maxOutputLength: USER_LICENSE_FILE_MAX_DOCUMENT_BYTES }).toString("utf8");
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
fail("authorization_invalid", "License file wrapper payload is invalid");
|
|
181
|
+
}
|
|
182
|
+
if (Buffer.byteLength(serialized, "utf8") > USER_LICENSE_FILE_MAX_DOCUMENT_BYTES) {
|
|
183
|
+
fail("authorization_invalid", "License file wrapper payload is too large");
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
return JSON.parse(serialized);
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
fail("authorization_invalid", "License file wrapper payload is invalid");
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
function publicLicenseFileHeader(document) {
|
|
193
|
+
return {
|
|
194
|
+
schemaVersion: document.schemaVersion,
|
|
195
|
+
type: document.type,
|
|
196
|
+
audience: document.audience,
|
|
197
|
+
serviceUrl: document.serviceUrl,
|
|
198
|
+
licenseId: document.licenseId,
|
|
199
|
+
channel: document.channel,
|
|
200
|
+
issuedAt: document.issuedAt,
|
|
201
|
+
expiresAt: document.expiresAt,
|
|
202
|
+
fileId: document.fileId,
|
|
203
|
+
encryption: document.encryption,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
function signedLicenseFileBody(document) {
|
|
207
|
+
return {
|
|
208
|
+
...publicLicenseFileHeader(document),
|
|
209
|
+
ciphertext: document.ciphertext,
|
|
210
|
+
tag: document.tag,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
function trustedKeyMap(keys) {
|
|
214
|
+
const entries = keys instanceof Map ? keys.entries() : Object.entries(keys ?? {});
|
|
215
|
+
return new Map([...entries].map(([key, value]) => [key, Buffer.from(value)]));
|
|
216
|
+
}
|
|
217
|
+
function parseEncryptedLicenseFile(value, options) {
|
|
218
|
+
const document = requiredObject(unwrapUserLicenseFile(value));
|
|
219
|
+
if (Object.keys(document).some((name) => !LICENSE_FILE_TOP_LEVEL_FIELDS.has(name)) ||
|
|
220
|
+
Object.keys(document).length !== LICENSE_FILE_TOP_LEVEL_FIELDS.size ||
|
|
221
|
+
document.schemaVersion !== 2 ||
|
|
222
|
+
document.type !== "slybrowser-license" ||
|
|
223
|
+
document.audience !== "slybrowser-license-file" ||
|
|
224
|
+
document.channel !== "stable") {
|
|
225
|
+
fail("authorization_invalid", "License file fields are invalid");
|
|
226
|
+
}
|
|
227
|
+
const serviceUrl = normalizeServiceUrl(requiredText(document, "serviceUrl"), options.allowInsecureLocalhost ?? false);
|
|
228
|
+
const trustedServiceUrls = (options.trustedServiceUrls ?? ["https://api.slybrowser.com"])
|
|
229
|
+
.map((url) => normalizeServiceUrl(url, options.allowInsecureLocalhost ?? false));
|
|
230
|
+
if (!trustedServiceUrls.includes(serviceUrl)) {
|
|
231
|
+
fail("license_file_untrusted_origin", "License file service URL is not trusted", 403);
|
|
232
|
+
}
|
|
233
|
+
const licenseId = requiredText(document, "licenseId", 64);
|
|
234
|
+
const issuedAt = Date.parse(requiredText(document, "issuedAt", 64));
|
|
235
|
+
const expiresAt = Date.parse(requiredText(document, "expiresAt", 64));
|
|
236
|
+
if (!/^[0-9a-f-]{36}$/.test(licenseId) || !Number.isFinite(issuedAt) ||
|
|
237
|
+
!Number.isFinite(expiresAt) || expiresAt <= issuedAt) {
|
|
238
|
+
fail("authorization_invalid", "License file identity fields are invalid");
|
|
239
|
+
}
|
|
240
|
+
if (expiresAt <= Date.now())
|
|
241
|
+
fail("license_file_expired", "License file has expired", 401);
|
|
242
|
+
requiredText(document, "fileId", 128);
|
|
243
|
+
const encryption = requiredObject(document.encryption);
|
|
244
|
+
const kdf = requiredObject(encryption.kdf);
|
|
245
|
+
const signature = requiredObject(document.signature);
|
|
246
|
+
const kdfName = requiredText(kdf, "name", 64);
|
|
247
|
+
const kdfPurpose = requiredText(kdf, "purpose", 64);
|
|
248
|
+
const expectedScope = (kdfName === "sly-test-scrypt-v1" && kdfPurpose === "test-private-preview") || (kdfName === "sly-portable-scrypt-v1" && kdfPurpose === "portable-passphrase") ? kdfPurpose : null;
|
|
249
|
+
if (requiredText(encryption, "algorithm", 64) !== "AES-256-GCM" ||
|
|
250
|
+
requiredText(encryption, "aad", 128) !== "slybrowser-license-v2-public-header" ||
|
|
251
|
+
expectedScope === null ||
|
|
252
|
+
requiredInteger(kdf, "cost") !== 16_384 ||
|
|
253
|
+
requiredInteger(kdf, "blockSize") !== 8 ||
|
|
254
|
+
requiredInteger(kdf, "parallelization") !== 1 ||
|
|
255
|
+
requiredInteger(kdf, "keyLength") !== 32 ||
|
|
256
|
+
requiredText(signature, "algorithm", 64) !== "Ed25519") {
|
|
257
|
+
fail("authorization_invalid", "License file algorithms are invalid");
|
|
258
|
+
}
|
|
259
|
+
const keyId = requiredText(signature, "keyId", 64);
|
|
260
|
+
const publicKey = trustedKeyMap(options.licenseFileTrustedKeys).get(keyId);
|
|
261
|
+
if (!publicKey)
|
|
262
|
+
fail("license_file_key_unknown", "License file signing key is not trusted", 403);
|
|
263
|
+
if (publicKey.length !== 32)
|
|
264
|
+
fail("license_file_key_invalid", "License file signing key is invalid", 403);
|
|
265
|
+
const signedBody = canonicalJson(signedLicenseFileBody({ ...document, serviceUrl }));
|
|
266
|
+
const verified = verifySignature(null, signedBody, createPublicKey({ key: Buffer.concat([ED25519_SPKI_PREFIX, publicKey]), format: "der", type: "spki" }), decodeBase64Url(requiredText(signature, "signature", 256), 64));
|
|
267
|
+
if (!verified)
|
|
268
|
+
fail("license_file_signature_invalid", "License file signature is invalid", 401);
|
|
269
|
+
const passphrase = options.licenseFilePassphrase;
|
|
270
|
+
if (typeof passphrase !== "string" || passphrase.length < 12) {
|
|
271
|
+
fail("license_file_locked", "License file passphrase is missing or too short", 401);
|
|
272
|
+
}
|
|
273
|
+
const salt = decodeBase64Url(requiredText(kdf, "salt", 64), 16);
|
|
274
|
+
if (salt.length !== 16)
|
|
275
|
+
fail("authorization_invalid", "License file salt length is invalid");
|
|
276
|
+
const nonce = decodeBase64Url(requiredText(encryption, "nonce", 64), 12);
|
|
277
|
+
if (nonce.length !== 12)
|
|
278
|
+
fail("authorization_invalid", "License file nonce length is invalid");
|
|
279
|
+
const decipher = createDecipheriv("aes-256-gcm", scryptSync(passphrase, salt, 32, { N: 16_384, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }), nonce);
|
|
280
|
+
decipher.setAAD(canonicalJson(publicLicenseFileHeader({ ...document, serviceUrl })));
|
|
281
|
+
decipher.setAuthTag(decodeBase64Url(requiredText(document, "tag", 64), 16));
|
|
282
|
+
let payload;
|
|
283
|
+
try {
|
|
284
|
+
const plaintext = Buffer.concat([
|
|
285
|
+
decipher.update(decodeBase64Url(requiredText(document, "ciphertext", 64 * 1024), 64 * 1024)),
|
|
286
|
+
decipher.final(),
|
|
287
|
+
]);
|
|
288
|
+
payload = requiredObject(JSON.parse(plaintext.toString("utf8")));
|
|
289
|
+
}
|
|
290
|
+
catch (error) {
|
|
291
|
+
if (error instanceof LicenseServiceError)
|
|
292
|
+
throw error;
|
|
293
|
+
fail("license_file_locked", "License file cannot be decrypted", 401);
|
|
294
|
+
}
|
|
295
|
+
if (Object.keys(payload).some((name) => !LICENSE_FILE_SECRET_FIELDS.has(name)) ||
|
|
296
|
+
Object.keys(payload).length !== LICENSE_FILE_SECRET_FIELDS.size) {
|
|
297
|
+
fail("license_file_payload_invalid", "License file payload contains unsupported claims");
|
|
298
|
+
}
|
|
299
|
+
const licenseKey = requiredText(payload, "licenseKey", 256);
|
|
300
|
+
if (payload.schemaVersion !== 2 ||
|
|
301
|
+
payload.type !== "slybrowser-license-secret" ||
|
|
302
|
+
payload.audience !== document.audience ||
|
|
303
|
+
payload.licenseId !== licenseId ||
|
|
304
|
+
payload.fileId !== document.fileId ||
|
|
305
|
+
normalizeServiceUrl(requiredText(payload, "serviceUrl"), options.allowInsecureLocalhost ?? false) !== serviceUrl ||
|
|
306
|
+
payload.channel !== "stable" ||
|
|
307
|
+
payload.secretVersion !== 1 ||
|
|
308
|
+
payload.expiresAt !== document.expiresAt ||
|
|
309
|
+
payload.scope !== expectedScope ||
|
|
310
|
+
!/^sly_live_[0-9a-f-]{36}\.[A-Za-z0-9_-]{40,}$/.test(licenseKey) ||
|
|
311
|
+
!licenseKey.startsWith(`sly_live_${licenseId}.`)) {
|
|
312
|
+
fail("license_file_payload_invalid", "License file payload does not match its public header");
|
|
313
|
+
}
|
|
314
|
+
return { schemaVersion: 1, serviceUrl, licenseKey, channel: "stable" };
|
|
315
|
+
}
|
|
316
|
+
function localLicenseFileUnlockRequested(options) {
|
|
317
|
+
return options.licenseFilePassphrase !== undefined || options.licenseFileTrustedKeys !== undefined;
|
|
318
|
+
}
|
|
319
|
+
async function resolveEncryptedLicenseFileViaService(value, options) {
|
|
320
|
+
const document = requiredObject(unwrapUserLicenseFile(value));
|
|
321
|
+
if (document.schemaVersion !== 2 || document.type !== "slybrowser-license" ||
|
|
322
|
+
document.audience !== "slybrowser-license-file" || document.channel !== "stable") {
|
|
323
|
+
fail("authorization_invalid", "License file fields are invalid");
|
|
324
|
+
}
|
|
325
|
+
const documentServiceUrl = normalizeServiceUrl(requiredText(document, "serviceUrl"), options.allowInsecureLocalhost ?? false);
|
|
326
|
+
assertTrustedAuthorizationOrigin(documentServiceUrl, options);
|
|
327
|
+
const resolutionServiceUrl = normalizeServiceUrl(options.licenseFileResolutionServiceUrl ?? documentServiceUrl, options.allowInsecureLocalhost ?? false);
|
|
328
|
+
const fetchImplementation = options.fetch ?? fetch;
|
|
329
|
+
let response;
|
|
330
|
+
try {
|
|
331
|
+
response = await fetchImplementation(`${resolutionServiceUrl}/v1/license-files/authorization`, {
|
|
332
|
+
method: "POST",
|
|
333
|
+
headers: {
|
|
334
|
+
"content-type": "application/json",
|
|
335
|
+
"user-agent": "SlyBrowserNodeSDK/license-file",
|
|
336
|
+
},
|
|
337
|
+
body: JSON.stringify({ schemaVersion: 1, licenseFile: value }),
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
fail("license_service_unreachable", "License-file authorization service is unreachable", 0);
|
|
342
|
+
}
|
|
343
|
+
const text = await response.text();
|
|
344
|
+
let parsed = {};
|
|
345
|
+
try {
|
|
346
|
+
parsed = text ? JSON.parse(text) : {};
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
fail("license_service_invalid_response", "License-file authorization service returned invalid JSON", response.status);
|
|
350
|
+
}
|
|
351
|
+
if (!response.ok) {
|
|
352
|
+
const error = parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
353
|
+
? parsed.error
|
|
354
|
+
: undefined;
|
|
355
|
+
const details = error && typeof error === "object" && !Array.isArray(error)
|
|
356
|
+
? error
|
|
357
|
+
: {};
|
|
358
|
+
const code = typeof details.code === "string" ? details.code : "license_file_authorization_failed";
|
|
359
|
+
const message = typeof details.message === "string"
|
|
360
|
+
? details.message
|
|
361
|
+
: "License-file authorization service rejected the license file";
|
|
362
|
+
throw new LicenseServiceError(message, code, response.status, details);
|
|
363
|
+
}
|
|
364
|
+
const authorization = parseAuthorization(parsed, options.allowInsecureLocalhost ?? false);
|
|
365
|
+
if (authorization.serviceUrl !== documentServiceUrl) {
|
|
366
|
+
fail("license_service_invalid_response", "License-file authorization service URL does not match the license file", response.status);
|
|
367
|
+
}
|
|
368
|
+
return authorization;
|
|
369
|
+
}
|
|
370
|
+
const SEALED_LICENSE_TOP_LEVEL_FIELDS = new Set([
|
|
371
|
+
"schemaVersion",
|
|
372
|
+
"type",
|
|
373
|
+
"audience",
|
|
374
|
+
"serviceUrl",
|
|
375
|
+
"channel",
|
|
376
|
+
"sealedAt",
|
|
377
|
+
"licenseKeySha256",
|
|
378
|
+
"protection",
|
|
379
|
+
"ciphertext",
|
|
380
|
+
]);
|
|
381
|
+
function licenseKeySha256(licenseKey) {
|
|
382
|
+
return createHash("sha256").update(licenseKey, "utf8").digest("hex");
|
|
383
|
+
}
|
|
384
|
+
function sealedLicenseEntropy(document) {
|
|
385
|
+
return [
|
|
386
|
+
"slybrowser-sealed-license-v1",
|
|
387
|
+
requiredText(document, "serviceUrl", 2048),
|
|
388
|
+
requiredText(document, "channel", 16),
|
|
389
|
+
requiredText(document, "licenseKeySha256", 64),
|
|
390
|
+
].join("\0");
|
|
391
|
+
}
|
|
392
|
+
function assertTrustedAuthorizationOrigin(serviceUrl, options) {
|
|
393
|
+
const trustedServiceUrls = (options.trustedServiceUrls ?? ["https://api.slybrowser.com"])
|
|
394
|
+
.map((url) => normalizeServiceUrl(url, options.allowInsecureLocalhost ?? false));
|
|
395
|
+
if (!trustedServiceUrls.includes(serviceUrl)) {
|
|
396
|
+
fail("license_file_untrusted_origin", "License file service URL is not trusted", 403);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
async function windowsDpapi(operation, data, entropy) {
|
|
400
|
+
if (process.platform !== "win32") {
|
|
401
|
+
fail("sealed_license_unsupported", "Windows DPAPI sealed license files are only supported on Windows", 400);
|
|
402
|
+
}
|
|
403
|
+
const script = `
|
|
404
|
+
$ErrorActionPreference = 'Stop'
|
|
405
|
+
[System.Reflection.Assembly]::LoadWithPartialName('System.Security') | Out-Null
|
|
406
|
+
$request = [Console]::In.ReadToEnd() | ConvertFrom-Json
|
|
407
|
+
$data = [Convert]::FromBase64String([string]$request.data)
|
|
408
|
+
$entropy = [Convert]::FromBase64String([string]$request.entropy)
|
|
409
|
+
$scope = [System.Security.Cryptography.DataProtectionScope]::CurrentUser
|
|
410
|
+
if ([string]$request.operation -eq 'protect') {
|
|
411
|
+
$output = [System.Security.Cryptography.ProtectedData]::Protect($data, $entropy, $scope)
|
|
412
|
+
} elseif ([string]$request.operation -eq 'unprotect') {
|
|
413
|
+
$output = [System.Security.Cryptography.ProtectedData]::Unprotect($data, $entropy, $scope)
|
|
414
|
+
} else {
|
|
415
|
+
throw 'Unsupported DPAPI operation'
|
|
416
|
+
}
|
|
417
|
+
[Console]::Out.Write([Convert]::ToBase64String($output))
|
|
418
|
+
`;
|
|
419
|
+
const encodedCommand = Buffer.from(script, "utf16le").toString("base64");
|
|
420
|
+
const request = JSON.stringify({
|
|
421
|
+
operation,
|
|
422
|
+
data: data.toString("base64"),
|
|
423
|
+
entropy: Buffer.from(entropy, "utf8").toString("base64"),
|
|
424
|
+
});
|
|
425
|
+
return await new Promise((resolvePromise, reject) => {
|
|
426
|
+
const child = spawn("powershell.exe", [
|
|
427
|
+
"-NoLogo",
|
|
428
|
+
"-NoProfile",
|
|
429
|
+
"-NonInteractive",
|
|
430
|
+
"-ExecutionPolicy",
|
|
431
|
+
"Bypass",
|
|
432
|
+
"-EncodedCommand",
|
|
433
|
+
encodedCommand,
|
|
434
|
+
], { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
|
|
435
|
+
const stdout = [];
|
|
436
|
+
const stderr = [];
|
|
437
|
+
let settled = false;
|
|
438
|
+
const rejectOnce = (error) => {
|
|
439
|
+
if (settled)
|
|
440
|
+
return;
|
|
441
|
+
settled = true;
|
|
442
|
+
reject(error);
|
|
443
|
+
};
|
|
444
|
+
child.stdout.on("data", (chunk) => {
|
|
445
|
+
stdout.push(chunk);
|
|
446
|
+
if (Buffer.concat(stdout).length > 16 * 1024) {
|
|
447
|
+
child.kill();
|
|
448
|
+
rejectOnce(new LicenseServiceError("Windows DPAPI returned an oversized response", "sealed_license_invalid", 400));
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
child.stderr.on("data", (chunk) => {
|
|
452
|
+
stderr.push(chunk);
|
|
453
|
+
if (Buffer.concat(stderr).length > 8 * 1024)
|
|
454
|
+
child.kill();
|
|
455
|
+
});
|
|
456
|
+
child.on("error", () => {
|
|
457
|
+
rejectOnce(new LicenseServiceError("Windows DPAPI is unavailable", "sealed_license_unsupported", 400));
|
|
458
|
+
});
|
|
459
|
+
child.on("close", (code) => {
|
|
460
|
+
if (settled)
|
|
461
|
+
return;
|
|
462
|
+
settled = true;
|
|
463
|
+
if (code !== 0) {
|
|
464
|
+
reject(new LicenseServiceError("Sealed license file cannot be decrypted on this Windows user or machine", "sealed_license_locked", 401));
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
try {
|
|
468
|
+
resolvePromise(Buffer.from(Buffer.concat(stdout).toString("utf8").trim(), "base64"));
|
|
469
|
+
}
|
|
470
|
+
catch {
|
|
471
|
+
reject(new LicenseServiceError("Windows DPAPI returned an invalid sealed license response", "sealed_license_invalid", 400));
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
child.stdin.end(request);
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
async function parseSealedLicenseFile(value, options) {
|
|
478
|
+
const document = requiredObject(value);
|
|
479
|
+
if (Object.keys(document).some((name) => !SEALED_LICENSE_TOP_LEVEL_FIELDS.has(name)) ||
|
|
480
|
+
Object.keys(document).length !== SEALED_LICENSE_TOP_LEVEL_FIELDS.size ||
|
|
481
|
+
document.schemaVersion !== 3 ||
|
|
482
|
+
document.type !== "slybrowser-sealed-authorization" ||
|
|
483
|
+
document.audience !== "slybrowser") {
|
|
484
|
+
fail("sealed_license_invalid", "Sealed license file fields are invalid");
|
|
485
|
+
}
|
|
486
|
+
const serviceUrl = normalizeServiceUrl(requiredText(document, "serviceUrl"), options.allowInsecureLocalhost ?? false);
|
|
487
|
+
assertTrustedAuthorizationOrigin(serviceUrl, options);
|
|
488
|
+
if (requiredText(document, "channel", 16) !== "stable")
|
|
489
|
+
fail("sealed_license_invalid", "Sealed license channel is invalid");
|
|
490
|
+
const sealedAt = Date.parse(requiredText(document, "sealedAt", 64));
|
|
491
|
+
if (!Number.isFinite(sealedAt))
|
|
492
|
+
fail("sealed_license_invalid", "Sealed license timestamp is invalid");
|
|
493
|
+
const sha256 = requiredText(document, "licenseKeySha256", 64);
|
|
494
|
+
if (!/^[0-9a-f]{64}$/.test(sha256))
|
|
495
|
+
fail("sealed_license_invalid", "Sealed license fingerprint is invalid");
|
|
496
|
+
const protection = requiredObject(document.protection);
|
|
497
|
+
if (Object.keys(protection).sort().join(",") !== "provider,scope" ||
|
|
498
|
+
protection.provider !== "windows-dpapi" ||
|
|
499
|
+
protection.scope !== "current-user") {
|
|
500
|
+
fail("sealed_license_unsupported", "Sealed license protection is not supported", 400);
|
|
501
|
+
}
|
|
502
|
+
const ciphertext = decodeBase64Url(requiredText(document, "ciphertext", 64 * 1024), 64 * 1024);
|
|
503
|
+
let parsed;
|
|
504
|
+
try {
|
|
505
|
+
const plaintext = await windowsDpapi("unprotect", ciphertext, sealedLicenseEntropy({ ...document, serviceUrl }));
|
|
506
|
+
parsed = JSON.parse(plaintext.toString("utf8"));
|
|
507
|
+
}
|
|
508
|
+
catch (error) {
|
|
509
|
+
if (error instanceof LicenseServiceError)
|
|
510
|
+
throw error;
|
|
511
|
+
fail("sealed_license_locked", "Sealed license file cannot be decrypted on this Windows user or machine", 401);
|
|
512
|
+
}
|
|
513
|
+
const authorization = parseAuthorization(parsed, options.allowInsecureLocalhost ?? false);
|
|
514
|
+
if (authorization.serviceUrl !== serviceUrl || authorization.channel !== "stable" ||
|
|
515
|
+
licenseKeySha256(authorization.licenseKey) !== sha256) {
|
|
516
|
+
fail("sealed_license_mismatch", "Sealed license payload does not match its public header", 400);
|
|
517
|
+
}
|
|
518
|
+
return authorization;
|
|
519
|
+
}
|
|
520
|
+
export async function importLicenseFileToSealedAuthorization(inputPath, outputPath, options = {}) {
|
|
521
|
+
const raw = await readFile(resolve(inputPath));
|
|
522
|
+
if (raw.length > 64 * 1024)
|
|
523
|
+
fail("authorization_invalid", "Authorization file is too large");
|
|
524
|
+
let parsed;
|
|
525
|
+
try {
|
|
526
|
+
parsed = JSON.parse(raw.toString("utf8"));
|
|
527
|
+
}
|
|
528
|
+
catch {
|
|
529
|
+
fail("authorization_invalid", "Authorization file is not valid JSON");
|
|
530
|
+
}
|
|
531
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || parsed.schemaVersion !== 2) {
|
|
532
|
+
fail("license_file_invalid", "Only encrypted v2 SlyBrowser license files can be imported", 400);
|
|
533
|
+
}
|
|
534
|
+
const authorization = parseEncryptedLicenseFile(parsed, options);
|
|
535
|
+
assertTrustedAuthorizationOrigin(authorization.serviceUrl, options);
|
|
536
|
+
const fingerprint = licenseKeySha256(authorization.licenseKey);
|
|
537
|
+
const sealedAt = new Date().toISOString();
|
|
538
|
+
const document = {
|
|
539
|
+
schemaVersion: 3,
|
|
540
|
+
type: "slybrowser-sealed-authorization",
|
|
541
|
+
audience: "slybrowser",
|
|
542
|
+
serviceUrl: authorization.serviceUrl,
|
|
543
|
+
channel: authorization.channel,
|
|
544
|
+
sealedAt,
|
|
545
|
+
licenseKeySha256: fingerprint,
|
|
546
|
+
protection: {
|
|
547
|
+
provider: "windows-dpapi",
|
|
548
|
+
scope: "current-user",
|
|
549
|
+
},
|
|
550
|
+
ciphertext: "",
|
|
551
|
+
};
|
|
552
|
+
const ciphertext = await windowsDpapi("protect", Buffer.from(JSON.stringify(authorization), "utf8"), sealedLicenseEntropy(document));
|
|
553
|
+
document.ciphertext = ciphertext.toString("base64url");
|
|
554
|
+
const output = resolve(outputPath);
|
|
555
|
+
await writeFile(output, `${JSON.stringify(document, null, 2)}\n`, { flag: "wx", mode: 0o600 });
|
|
556
|
+
return {
|
|
557
|
+
output,
|
|
558
|
+
serviceUrl: authorization.serviceUrl,
|
|
559
|
+
channel: "stable",
|
|
560
|
+
protection: "windows-dpapi-current-user",
|
|
561
|
+
licenseKeySha256: fingerprint,
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
export async function readLicenseAuthorization(path, options = {}) {
|
|
565
|
+
const raw = await readFile(resolve(path));
|
|
566
|
+
if (raw.length > 64 * 1024)
|
|
567
|
+
fail("authorization_invalid", "Authorization file is too large");
|
|
568
|
+
let parsed;
|
|
569
|
+
try {
|
|
570
|
+
parsed = JSON.parse(raw.toString("utf8"));
|
|
571
|
+
}
|
|
572
|
+
catch {
|
|
573
|
+
fail("authorization_invalid", "Authorization file is not valid JSON");
|
|
574
|
+
}
|
|
575
|
+
try {
|
|
576
|
+
const document = parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
577
|
+
? unwrapUserLicenseFile(parsed)
|
|
578
|
+
: parsed;
|
|
579
|
+
if (document && typeof document === "object" && !Array.isArray(document) &&
|
|
580
|
+
document.schemaVersion === 2) {
|
|
581
|
+
if (!localLicenseFileUnlockRequested(options)) {
|
|
582
|
+
return await resolveEncryptedLicenseFileViaService(parsed, options);
|
|
583
|
+
}
|
|
584
|
+
return parseEncryptedLicenseFile(parsed, options);
|
|
585
|
+
}
|
|
586
|
+
if (document && typeof document === "object" && !Array.isArray(document) &&
|
|
587
|
+
document.schemaVersion === 3) {
|
|
588
|
+
return await parseSealedLicenseFile(parsed, options);
|
|
589
|
+
}
|
|
590
|
+
return parseAuthorization(parsed, options.allowInsecureLocalhost ?? false);
|
|
591
|
+
}
|
|
592
|
+
catch (error) {
|
|
593
|
+
if (error instanceof LicenseServiceError)
|
|
594
|
+
throw error;
|
|
595
|
+
fail("authorization_invalid", "Authorization file is not valid JSON");
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
function currentPlatform() {
|
|
599
|
+
if (process.platform === "win32")
|
|
600
|
+
return "windows";
|
|
601
|
+
if (process.platform === "linux")
|
|
602
|
+
return "linux";
|
|
603
|
+
if (process.platform === "darwin")
|
|
604
|
+
return "macos";
|
|
605
|
+
fail("platform_unsupported", `Unsupported platform: ${process.platform}`);
|
|
606
|
+
}
|
|
607
|
+
function currentArch() {
|
|
608
|
+
if (process.arch === "x64" || process.arch === "arm64")
|
|
609
|
+
return process.arch;
|
|
610
|
+
fail("platform_unsupported", `Unsupported architecture: ${process.arch}`);
|
|
611
|
+
}
|
|
612
|
+
function object(value, code) {
|
|
613
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
614
|
+
fail(code, "License service returned an invalid response");
|
|
615
|
+
return value;
|
|
616
|
+
}
|
|
617
|
+
function versionParts(value) {
|
|
618
|
+
if (!/^\d+(\.\d+){0,7}$/.test(value))
|
|
619
|
+
fail("browser_version_invalid", `Browser version is invalid: ${value}`);
|
|
620
|
+
return value.split(".").map(Number);
|
|
621
|
+
}
|
|
622
|
+
function compareVersion(left, right) {
|
|
623
|
+
const a = versionParts(left);
|
|
624
|
+
const b = versionParts(right);
|
|
625
|
+
for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
|
|
626
|
+
const difference = (a[index] ?? 0) - (b[index] ?? 0);
|
|
627
|
+
if (difference !== 0)
|
|
628
|
+
return Math.sign(difference);
|
|
629
|
+
}
|
|
630
|
+
return 0;
|
|
631
|
+
}
|
|
632
|
+
function newStartupId() {
|
|
633
|
+
return `st_${randomUUID().replaceAll("-", "")}`;
|
|
634
|
+
}
|
|
635
|
+
function requiredLeaseFeatures(backend) {
|
|
636
|
+
const base = ["browser", "release-download", "webdriver"];
|
|
637
|
+
if (backend === "playwright")
|
|
638
|
+
return [...base, "playwright"];
|
|
639
|
+
if (backend === "puppeteer")
|
|
640
|
+
return [...base, "puppeteer"];
|
|
641
|
+
return base;
|
|
642
|
+
}
|
|
643
|
+
function parseResponseFeatures(value, fallback) {
|
|
644
|
+
const source = value === undefined ? fallback : value;
|
|
645
|
+
if (!Array.isArray(source) ||
|
|
646
|
+
source.some((item) => typeof item !== "string" || !item || item.length > 128) ||
|
|
647
|
+
new Set(source).size !== source.length) {
|
|
648
|
+
fail("license_service_invalid_response", "License service features response is invalid");
|
|
649
|
+
}
|
|
650
|
+
return Object.freeze([...source]);
|
|
651
|
+
}
|
|
652
|
+
function parseRequestedKernelMajor(value) {
|
|
653
|
+
if (value === undefined)
|
|
654
|
+
return undefined;
|
|
655
|
+
if (value === "latest")
|
|
656
|
+
return "latest";
|
|
657
|
+
if (typeof value === "number" && Number.isSafeInteger(value) && value > 0)
|
|
658
|
+
return value;
|
|
659
|
+
fail("license_service_invalid_response", "License service requested-kernel response is invalid");
|
|
660
|
+
}
|
|
661
|
+
function parseSelectionMode(value) {
|
|
662
|
+
if (value === undefined)
|
|
663
|
+
return undefined;
|
|
664
|
+
if (new Set(["latest", "latest-in-major", "cached-approved", "exact", "rollback"]).has(String(value))) {
|
|
665
|
+
return value;
|
|
666
|
+
}
|
|
667
|
+
fail("license_service_invalid_response", "License service selection-mode response is invalid");
|
|
668
|
+
}
|
|
669
|
+
function parseOptionalVersion(value, field) {
|
|
670
|
+
if (value === undefined)
|
|
671
|
+
return undefined;
|
|
672
|
+
if (typeof value === "string" && /^\d+(\.\d+){0,7}$/.test(value))
|
|
673
|
+
return value;
|
|
674
|
+
fail("license_service_invalid_response", `License service ${field} response is invalid`);
|
|
675
|
+
}
|
|
676
|
+
function parseOptionalBoolean(value, field) {
|
|
677
|
+
if (value === undefined)
|
|
678
|
+
return undefined;
|
|
679
|
+
if (typeof value === "boolean")
|
|
680
|
+
return value;
|
|
681
|
+
fail("license_service_invalid_response", `License service ${field} response is invalid`);
|
|
682
|
+
}
|
|
683
|
+
function sameStringSet(left, right) {
|
|
684
|
+
return left.length === right.length && left.every((item) => right.includes(item));
|
|
685
|
+
}
|
|
686
|
+
function assertClaimsMatchPlan(claims, plan, concurrencyLimit, features) {
|
|
687
|
+
if (claims.planId !== undefined && claims.planId !== plan) {
|
|
688
|
+
fail("license_service_invalid_response", "Signed lease plan does not match the service response");
|
|
689
|
+
}
|
|
690
|
+
if (claims.concurrencyLimit !== undefined && claims.concurrencyLimit !== concurrencyLimit) {
|
|
691
|
+
fail("license_service_invalid_response", "Signed lease concurrency does not match the service response");
|
|
692
|
+
}
|
|
693
|
+
if (!sameStringSet(claims.features, features)) {
|
|
694
|
+
fail("license_service_invalid_response", "Signed lease features do not match the service response");
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
export class LicenseServiceClient {
|
|
698
|
+
authorization;
|
|
699
|
+
#fetch;
|
|
700
|
+
#licenseVerifier;
|
|
701
|
+
#releaseTrustedKeys;
|
|
702
|
+
constructor(authorization, options) {
|
|
703
|
+
this.authorization = parseAuthorization(authorization, options.allowInsecureLocalhost ?? false);
|
|
704
|
+
this.#fetch = options.fetch ?? fetch;
|
|
705
|
+
this.#licenseVerifier = new LicenseVerifier(options.licenseTrustedKeys);
|
|
706
|
+
this.#releaseTrustedKeys = options.releaseTrustedKeys;
|
|
707
|
+
}
|
|
708
|
+
async licenseInfo(options = {}) {
|
|
709
|
+
const platform = options.platform ?? currentPlatform();
|
|
710
|
+
const arch = options.arch ?? currentArch();
|
|
711
|
+
const sdkVersion = options.sdkVersion ?? "0.1.0";
|
|
712
|
+
const versionPolicy = options.versionPolicy ?? (options.browserVersion === undefined ? "latest" : "exact");
|
|
713
|
+
if (versionPolicy === "latest" && options.browserVersion !== undefined) {
|
|
714
|
+
fail("version_policy_invalid", "Latest selection cannot include a requested browser version");
|
|
715
|
+
}
|
|
716
|
+
if (versionPolicy !== "latest" && options.browserVersion === undefined) {
|
|
717
|
+
fail("version_policy_invalid", `${versionPolicy} selection requires a browser version`);
|
|
718
|
+
}
|
|
719
|
+
if (options.browserVersion !== undefined)
|
|
720
|
+
versionParts(options.browserVersion);
|
|
721
|
+
if (options.kernelMajor !== undefined && options.kernelMajor !== "latest" &&
|
|
722
|
+
(!Number.isSafeInteger(options.kernelMajor) || options.kernelMajor < 1)) {
|
|
723
|
+
fail("version_policy_invalid", "kernelMajor must be a positive integer or latest");
|
|
724
|
+
}
|
|
725
|
+
if (options.browserVersion !== undefined && options.kernelMajor !== undefined && options.kernelMajor !== "latest" &&
|
|
726
|
+
Number(options.browserVersion.split(".")[0]) !== options.kernelMajor) {
|
|
727
|
+
fail("version_policy_invalid", "browserVersion does not match kernelMajor");
|
|
728
|
+
}
|
|
729
|
+
const value = object(await this.#request("POST", "/v2/licenses/info", {
|
|
730
|
+
authorization: `License ${this.authorization.licenseKey}`,
|
|
731
|
+
body: {
|
|
732
|
+
platform,
|
|
733
|
+
arch,
|
|
734
|
+
channel: this.authorization.channel,
|
|
735
|
+
sdkVersion,
|
|
736
|
+
...(options.deviceHash === undefined ? {} : { deviceHash: options.deviceHash }),
|
|
737
|
+
...(options.kernelMajor === undefined ? {} : { kernelMajor: options.kernelMajor }),
|
|
738
|
+
...(options.updateKernel === undefined ? {} : { updateKernel: options.updateKernel }),
|
|
739
|
+
versionPolicy,
|
|
740
|
+
...(options.browserVersion === undefined ? {} : { browserVersion: options.browserVersion }),
|
|
741
|
+
},
|
|
742
|
+
}), "license_service_invalid_response");
|
|
743
|
+
const plan = String(value.plan ?? "");
|
|
744
|
+
const effectivePlan = String(value.effectivePlan ?? "");
|
|
745
|
+
const licenseStatus = String(value.licenseStatus ?? "");
|
|
746
|
+
const browserVersion = String(value.browserVersion ?? "");
|
|
747
|
+
const requestedBrowserVersion = value.requestedBrowserVersion;
|
|
748
|
+
const availableBrowserVersions = value.availableBrowserVersions;
|
|
749
|
+
const updateRights = value.updateRights;
|
|
750
|
+
const requestedKernelMajor = parseRequestedKernelMajor(value.requestedKernelMajor);
|
|
751
|
+
const selectionMode = parseSelectionMode(value.selectionMode);
|
|
752
|
+
const latestAvailableVersion = parseOptionalVersion(value.latestAvailableVersion, "latest-available-version");
|
|
753
|
+
const updateAvailable = parseOptionalBoolean(value.updateAvailable, "update-available");
|
|
754
|
+
const updateRequired = parseOptionalBoolean(value.updateRequired, "update-required");
|
|
755
|
+
const sessionState = value.sessionState;
|
|
756
|
+
const plans = new Set(["free", "basic", "pro", "max", "ultra"]);
|
|
757
|
+
if (value.schemaVersion !== 1 || value.channel !== "stable" ||
|
|
758
|
+
!new Set(["active", "hold", "revoked"]).has(licenseStatus) ||
|
|
759
|
+
!plans.has(plan) || !plans.has(effectivePlan) ||
|
|
760
|
+
!Number.isSafeInteger(value.concurrencyLimit) ||
|
|
761
|
+
!Number.isSafeInteger(value.activeSessions) ||
|
|
762
|
+
!Number.isSafeInteger(value.availableSessions) ||
|
|
763
|
+
!browserVersion || !/^\d+(\.\d+){0,7}$/.test(browserVersion) ||
|
|
764
|
+
value.versionPolicy !== versionPolicy ||
|
|
765
|
+
!new Set(["latest", "exact", "rollback"]).has(String(value.selectionReason ?? "")) ||
|
|
766
|
+
(options.browserVersion === undefined ? requestedBrowserVersion !== undefined : requestedBrowserVersion !== options.browserVersion) ||
|
|
767
|
+
!Array.isArray(availableBrowserVersions) ||
|
|
768
|
+
!availableBrowserVersions.every((item) => typeof item === "string" && /^\d+(\.\d+){0,7}$/.test(item)) ||
|
|
769
|
+
!updateRights || updateRights.status !== "active" || updateRights.channel !== "stable" ||
|
|
770
|
+
(updateRights.updatesThrough !== null && !Number.isSafeInteger(updateRights.updatesThrough)) ||
|
|
771
|
+
updateRights.exactVersion !== true || updateRights.rollback !== true ||
|
|
772
|
+
value.stableErrorCode !== null ||
|
|
773
|
+
!sessionState ||
|
|
774
|
+
sessionState.activeBrowserProcesses !== value.activeSessions ||
|
|
775
|
+
sessionState.limit !== value.concurrencyLimit ||
|
|
776
|
+
sessionState.available !== value.availableSessions) {
|
|
777
|
+
fail("license_service_invalid_response", "License service info response is invalid");
|
|
778
|
+
}
|
|
779
|
+
const features = parseResponseFeatures(value.features, []);
|
|
780
|
+
const result = {
|
|
781
|
+
schemaVersion: 1,
|
|
782
|
+
channel: "stable",
|
|
783
|
+
licenseStatus: licenseStatus,
|
|
784
|
+
plan: plan,
|
|
785
|
+
effectivePlan: effectivePlan,
|
|
786
|
+
paidThrough: value.paidThrough === null ? null : Number(value.paidThrough),
|
|
787
|
+
features,
|
|
788
|
+
concurrencyLimit: Number(value.concurrencyLimit),
|
|
789
|
+
activeSessions: Number(value.activeSessions),
|
|
790
|
+
availableSessions: Number(value.availableSessions),
|
|
791
|
+
sessionState: {
|
|
792
|
+
activeBrowserProcesses: Number(sessionState.activeBrowserProcesses),
|
|
793
|
+
limit: Number(sessionState.limit),
|
|
794
|
+
available: Number(sessionState.available),
|
|
795
|
+
},
|
|
796
|
+
browserVersion,
|
|
797
|
+
...(requestedBrowserVersion === undefined ? {} : { requestedBrowserVersion: String(requestedBrowserVersion) }),
|
|
798
|
+
...(requestedKernelMajor === undefined ? {} : { requestedKernelMajor }),
|
|
799
|
+
versionPolicy,
|
|
800
|
+
selectionReason: String(value.selectionReason),
|
|
801
|
+
...(selectionMode === undefined ? {} : { selectionMode }),
|
|
802
|
+
availableBrowserVersions: availableBrowserVersions,
|
|
803
|
+
...(latestAvailableVersion === undefined ? {} : { latestAvailableVersion }),
|
|
804
|
+
...(updateAvailable === undefined ? {} : { updateAvailable }),
|
|
805
|
+
...(updateRequired === undefined ? {} : { updateRequired }),
|
|
806
|
+
updateRights: updateRights,
|
|
807
|
+
stableErrorCode: null,
|
|
808
|
+
};
|
|
809
|
+
if (value.paidThrough !== null && !Number.isSafeInteger(value.paidThrough)) {
|
|
810
|
+
fail("license_service_invalid_response", "License service paid-through response is invalid");
|
|
811
|
+
}
|
|
812
|
+
return result;
|
|
813
|
+
}
|
|
814
|
+
async createSession(options = {}) {
|
|
815
|
+
const platform = options.platform ?? currentPlatform();
|
|
816
|
+
const arch = options.arch ?? currentArch();
|
|
817
|
+
const sdkVersion = options.sdkVersion ?? "0.1.0";
|
|
818
|
+
const versionPolicy = options.versionPolicy ?? (options.browserVersion === undefined ? "latest" : "exact");
|
|
819
|
+
if (versionPolicy === "latest" && options.browserVersion !== undefined) {
|
|
820
|
+
fail("version_policy_invalid", "Latest selection cannot include a requested browser version");
|
|
821
|
+
}
|
|
822
|
+
if (versionPolicy !== "latest" && options.browserVersion === undefined) {
|
|
823
|
+
fail("version_policy_invalid", `${versionPolicy} selection requires a browser version`);
|
|
824
|
+
}
|
|
825
|
+
if (options.browserVersion !== undefined)
|
|
826
|
+
versionParts(options.browserVersion);
|
|
827
|
+
if (options.kernelMajor !== undefined && options.kernelMajor !== "latest" &&
|
|
828
|
+
(!Number.isSafeInteger(options.kernelMajor) || options.kernelMajor < 1)) {
|
|
829
|
+
fail("version_policy_invalid", "kernelMajor must be a positive integer or latest");
|
|
830
|
+
}
|
|
831
|
+
if (options.browserVersion !== undefined && options.kernelMajor !== undefined && options.kernelMajor !== "latest" &&
|
|
832
|
+
Number(options.browserVersion.split(".")[0]) !== options.kernelMajor) {
|
|
833
|
+
fail("version_policy_invalid", "browserVersion does not match kernelMajor");
|
|
834
|
+
}
|
|
835
|
+
const value = object(await this.#request("POST", "/v1/licenses/sessions", {
|
|
836
|
+
authorization: `License ${this.authorization.licenseKey}`,
|
|
837
|
+
body: {
|
|
838
|
+
platform,
|
|
839
|
+
arch,
|
|
840
|
+
channel: this.authorization.channel,
|
|
841
|
+
sdkVersion,
|
|
842
|
+
...(options.deviceHash === undefined ? {} : { deviceHash: options.deviceHash }),
|
|
843
|
+
...(options.kernelMajor === undefined ? {} : { kernelMajor: options.kernelMajor }),
|
|
844
|
+
...(options.updateKernel === undefined ? {} : { updateKernel: options.updateKernel }),
|
|
845
|
+
versionPolicy,
|
|
846
|
+
...(options.browserVersion === undefined ? {} : { browserVersion: options.browserVersion }),
|
|
847
|
+
},
|
|
848
|
+
}), "license_service_invalid_response");
|
|
849
|
+
const sessionId = String(value.sessionId ?? "");
|
|
850
|
+
const sessionToken = String(value.sessionToken ?? "");
|
|
851
|
+
const browserVersion = String(value.browserVersion ?? "");
|
|
852
|
+
if (!sessionId || !sessionToken || !browserVersion || !Number.isSafeInteger(value.expiresAt) ||
|
|
853
|
+
!Number.isSafeInteger(value.heartbeatAfterSeconds)) {
|
|
854
|
+
fail("license_service_invalid_response", "License service session response is invalid");
|
|
855
|
+
}
|
|
856
|
+
const returnedPolicy = String(value.versionPolicy ?? "");
|
|
857
|
+
const selectionReason = String(value.selectionReason ?? "");
|
|
858
|
+
const requestedBrowserVersion = value.requestedBrowserVersion;
|
|
859
|
+
const availableBrowserVersions = value.availableBrowserVersions;
|
|
860
|
+
const updateRights = value.updateRights;
|
|
861
|
+
const requestedKernelMajor = parseRequestedKernelMajor(value.requestedKernelMajor);
|
|
862
|
+
const selectionMode = parseSelectionMode(value.selectionMode);
|
|
863
|
+
const latestAvailableVersion = parseOptionalVersion(value.latestAvailableVersion, "latest-available-version");
|
|
864
|
+
const updateAvailable = parseOptionalBoolean(value.updateAvailable, "update-available");
|
|
865
|
+
const updateRequired = parseOptionalBoolean(value.updateRequired, "update-required");
|
|
866
|
+
if (returnedPolicy !== versionPolicy ||
|
|
867
|
+
!new Set(["latest", "exact", "rollback"]).has(selectionReason) ||
|
|
868
|
+
(options.browserVersion === undefined
|
|
869
|
+
? requestedBrowserVersion !== undefined
|
|
870
|
+
: requestedBrowserVersion !== options.browserVersion) ||
|
|
871
|
+
!Array.isArray(availableBrowserVersions) ||
|
|
872
|
+
!availableBrowserVersions.every((item) => typeof item === "string" && /^\d+(\.\d+){0,7}$/.test(item)) ||
|
|
873
|
+
!updateRights || updateRights.status !== "active" || updateRights.channel !== "stable" ||
|
|
874
|
+
(updateRights.updatesThrough !== null && !Number.isSafeInteger(updateRights.updatesThrough)) ||
|
|
875
|
+
updateRights.exactVersion !== true || updateRights.rollback !== true) {
|
|
876
|
+
fail("license_service_invalid_response", "License service version-selection response is invalid");
|
|
877
|
+
}
|
|
878
|
+
if (versionPolicy === "exact" && browserVersion !== options.browserVersion) {
|
|
879
|
+
fail("release_version_mismatch", `Requested browser ${options.browserVersion} but service selected ${browserVersion}`);
|
|
880
|
+
}
|
|
881
|
+
if (versionPolicy === "at-or-before" && compareVersion(browserVersion, options.browserVersion) > 0) {
|
|
882
|
+
fail("release_version_mismatch", `Rollback selection ${browserVersion} is newer than requested ${options.browserVersion}`);
|
|
883
|
+
}
|
|
884
|
+
const lease = value.lease;
|
|
885
|
+
const claims = this.#licenseVerifier.verify(lease, {
|
|
886
|
+
browserVersion,
|
|
887
|
+
requiredFeatures: requiredLeaseFeatures(),
|
|
888
|
+
...(options.deviceHash === undefined ? {} : { deviceHash: options.deviceHash }),
|
|
889
|
+
});
|
|
890
|
+
if (claims.sessionId !== sessionId || claims.expiresAt !== value.expiresAt) {
|
|
891
|
+
fail("license_service_invalid_response", "Signed lease does not match the allocated session");
|
|
892
|
+
}
|
|
893
|
+
const manifest = verifyReleaseManifest(value.manifest, this.#releaseTrustedKeys);
|
|
894
|
+
if (manifest.browserVersion !== browserVersion) {
|
|
895
|
+
fail("license_service_invalid_response", "Release manifest does not match the signed lease");
|
|
896
|
+
}
|
|
897
|
+
if (!isSdkCompatible(manifest.sdkCompatibility, sdkVersion)) {
|
|
898
|
+
fail("sdk_version_unsupported", `Browser ${browserVersion} does not support SDK ${sdkVersion}`);
|
|
899
|
+
}
|
|
900
|
+
const artifact = selectArtifact(manifest, platform, arch);
|
|
901
|
+
const serviceOrigin = new URL(this.authorization.serviceUrl).origin;
|
|
902
|
+
if (new URL(artifact.url).origin !== serviceOrigin) {
|
|
903
|
+
fail("artifact_origin_invalid", "Authorized artifacts must be served by the license service origin");
|
|
904
|
+
}
|
|
905
|
+
const plan = value.plan;
|
|
906
|
+
if (!new Set(["free", "basic", "pro", "max", "ultra"]).has(String(plan)) ||
|
|
907
|
+
!Number.isSafeInteger(value.concurrencyLimit) || !Number.isSafeInteger(value.activeSessions)) {
|
|
908
|
+
fail("license_service_invalid_response", "License service plan response is invalid");
|
|
909
|
+
}
|
|
910
|
+
const features = parseResponseFeatures(value.features, claims.features);
|
|
911
|
+
assertClaimsMatchPlan(claims, plan, Number(value.concurrencyLimit), features);
|
|
912
|
+
return {
|
|
913
|
+
sessionId,
|
|
914
|
+
sessionToken,
|
|
915
|
+
heartbeatAfterSeconds: Number(value.heartbeatAfterSeconds),
|
|
916
|
+
expiresAt: Number(value.expiresAt),
|
|
917
|
+
plan: plan,
|
|
918
|
+
features,
|
|
919
|
+
concurrencyLimit: Number(value.concurrencyLimit),
|
|
920
|
+
activeSessions: Number(value.activeSessions),
|
|
921
|
+
browserVersion,
|
|
922
|
+
...(requestedBrowserVersion === undefined ? {} : { requestedBrowserVersion: String(requestedBrowserVersion) }),
|
|
923
|
+
...(requestedKernelMajor === undefined ? {} : { requestedKernelMajor }),
|
|
924
|
+
versionPolicy,
|
|
925
|
+
selectionReason: selectionReason,
|
|
926
|
+
...(selectionMode === undefined ? {} : { selectionMode }),
|
|
927
|
+
availableBrowserVersions: availableBrowserVersions,
|
|
928
|
+
...(latestAvailableVersion === undefined ? {} : { latestAvailableVersion }),
|
|
929
|
+
...(updateAvailable === undefined ? {} : { updateAvailable }),
|
|
930
|
+
...(updateRequired === undefined ? {} : { updateRequired }),
|
|
931
|
+
updateRights: updateRights,
|
|
932
|
+
lease,
|
|
933
|
+
claims,
|
|
934
|
+
manifest,
|
|
935
|
+
artifact,
|
|
936
|
+
platform,
|
|
937
|
+
arch,
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
async createRuntimeSession(options = {}) {
|
|
941
|
+
const platform = options.platform ?? currentPlatform();
|
|
942
|
+
const arch = options.arch ?? currentArch();
|
|
943
|
+
const sdkVersion = options.sdkVersion ?? "0.1.0";
|
|
944
|
+
const startupId = options.startupId ?? newStartupId();
|
|
945
|
+
if (!/^st_[A-Za-z0-9_-]{16,120}$/.test(startupId)) {
|
|
946
|
+
fail("startup_id_invalid", "Runtime startup ID is invalid");
|
|
947
|
+
}
|
|
948
|
+
const versionPolicy = options.versionPolicy ?? (options.browserVersion === undefined ? "latest" : "exact");
|
|
949
|
+
if (versionPolicy === "latest" && options.browserVersion !== undefined) {
|
|
950
|
+
fail("version_policy_invalid", "Latest selection cannot include a requested browser version");
|
|
951
|
+
}
|
|
952
|
+
if (versionPolicy !== "latest" && options.browserVersion === undefined) {
|
|
953
|
+
fail("version_policy_invalid", `${versionPolicy} selection requires a browser version`);
|
|
954
|
+
}
|
|
955
|
+
if (options.browserVersion !== undefined)
|
|
956
|
+
versionParts(options.browserVersion);
|
|
957
|
+
if (options.kernelMajor !== undefined && options.kernelMajor !== "latest" &&
|
|
958
|
+
(!Number.isSafeInteger(options.kernelMajor) || options.kernelMajor < 1)) {
|
|
959
|
+
fail("version_policy_invalid", "kernelMajor must be a positive integer or latest");
|
|
960
|
+
}
|
|
961
|
+
if (options.browserVersion !== undefined && options.kernelMajor !== undefined && options.kernelMajor !== "latest" &&
|
|
962
|
+
Number(options.browserVersion.split(".")[0]) !== options.kernelMajor) {
|
|
963
|
+
fail("version_policy_invalid", "browserVersion does not match kernelMajor");
|
|
964
|
+
}
|
|
965
|
+
const value = object(await this.#request("POST", "/v2/runtime/sessions", {
|
|
966
|
+
authorization: `License ${this.authorization.licenseKey}`,
|
|
967
|
+
body: {
|
|
968
|
+
startupId,
|
|
969
|
+
platform,
|
|
970
|
+
arch,
|
|
971
|
+
channel: this.authorization.channel,
|
|
972
|
+
sdkVersion,
|
|
973
|
+
...(options.automationBackend === undefined ? {} : { automationBackend: options.automationBackend }),
|
|
974
|
+
...(options.deviceHash === undefined ? {} : { deviceHash: options.deviceHash }),
|
|
975
|
+
...(options.kernelMajor === undefined ? {} : { kernelMajor: options.kernelMajor }),
|
|
976
|
+
...(options.updateKernel === undefined ? {} : { updateKernel: options.updateKernel }),
|
|
977
|
+
versionPolicy,
|
|
978
|
+
...(options.browserVersion === undefined ? {} : { browserVersion: options.browserVersion }),
|
|
979
|
+
},
|
|
980
|
+
}), "license_service_invalid_response");
|
|
981
|
+
if (value.schemaVersion !== 2 || value.state !== "reserved" && value.state !== "active" && value.state !== "closing" ||
|
|
982
|
+
value.startupId !== startupId) {
|
|
983
|
+
fail("license_service_invalid_response", "Runtime session response is invalid");
|
|
984
|
+
}
|
|
985
|
+
const sessionId = String(value.sessionId ?? "");
|
|
986
|
+
const bootstrapToken = String(value.bootstrapToken ?? "");
|
|
987
|
+
const activationTicket = String(value.activationTicket ?? "");
|
|
988
|
+
const driverActivationTicket = value.driverActivationTicket;
|
|
989
|
+
const browserVersion = String(value.browserVersion ?? "");
|
|
990
|
+
if (!sessionId || !bootstrapToken || !activationTicket || !browserVersion || !Number.isSafeInteger(value.expiresAt) ||
|
|
991
|
+
!Number.isSafeInteger(value.heartbeatAfterSeconds)) {
|
|
992
|
+
fail("license_service_invalid_response", "Runtime session response is invalid");
|
|
993
|
+
}
|
|
994
|
+
if (driverActivationTicket !== undefined && (typeof driverActivationTicket !== "string" || driverActivationTicket.length === 0)) {
|
|
995
|
+
fail("license_service_invalid_response", "Runtime driver activation ticket is invalid");
|
|
996
|
+
}
|
|
997
|
+
if (options.automationBackend === "project-webdriver" && driverActivationTicket === undefined) {
|
|
998
|
+
fail("license_service_invalid_response", "Project WebDriver runtime session is missing a driver activation ticket");
|
|
999
|
+
}
|
|
1000
|
+
const returnedPolicy = String(value.versionPolicy ?? "");
|
|
1001
|
+
const selectionReason = String(value.selectionReason ?? "");
|
|
1002
|
+
const requestedBrowserVersion = value.requestedBrowserVersion;
|
|
1003
|
+
const availableBrowserVersions = value.availableBrowserVersions;
|
|
1004
|
+
const updateRights = value.updateRights;
|
|
1005
|
+
const requestedKernelMajor = parseRequestedKernelMajor(value.requestedKernelMajor);
|
|
1006
|
+
const selectionMode = parseSelectionMode(value.selectionMode);
|
|
1007
|
+
const latestAvailableVersion = parseOptionalVersion(value.latestAvailableVersion, "latest-available-version");
|
|
1008
|
+
const updateAvailable = parseOptionalBoolean(value.updateAvailable, "update-available");
|
|
1009
|
+
const updateRequired = parseOptionalBoolean(value.updateRequired, "update-required");
|
|
1010
|
+
if (returnedPolicy !== versionPolicy ||
|
|
1011
|
+
!new Set(["latest", "exact", "rollback"]).has(selectionReason) ||
|
|
1012
|
+
(options.browserVersion === undefined
|
|
1013
|
+
? requestedBrowserVersion !== undefined
|
|
1014
|
+
: requestedBrowserVersion !== options.browserVersion) ||
|
|
1015
|
+
!Array.isArray(availableBrowserVersions) ||
|
|
1016
|
+
!availableBrowserVersions.every((item) => typeof item === "string" && /^\d+(\.\d+){0,7}$/.test(item)) ||
|
|
1017
|
+
!updateRights || updateRights.status !== "active" || updateRights.channel !== "stable" ||
|
|
1018
|
+
(updateRights.updatesThrough !== null && !Number.isSafeInteger(updateRights.updatesThrough)) ||
|
|
1019
|
+
updateRights.exactVersion !== true || updateRights.rollback !== true) {
|
|
1020
|
+
fail("license_service_invalid_response", "Runtime version-selection response is invalid");
|
|
1021
|
+
}
|
|
1022
|
+
if (versionPolicy === "exact" && browserVersion !== options.browserVersion) {
|
|
1023
|
+
fail("release_version_mismatch", `Requested browser ${options.browserVersion} but service selected ${browserVersion}`);
|
|
1024
|
+
}
|
|
1025
|
+
if (versionPolicy === "at-or-before" && compareVersion(browserVersion, options.browserVersion) > 0) {
|
|
1026
|
+
fail("release_version_mismatch", `Rollback selection ${browserVersion} is newer than requested ${options.browserVersion}`);
|
|
1027
|
+
}
|
|
1028
|
+
const lease = value.lease;
|
|
1029
|
+
const claims = this.#licenseVerifier.verify(lease, {
|
|
1030
|
+
browserVersion,
|
|
1031
|
+
requiredFeatures: requiredLeaseFeatures(options.automationBackend),
|
|
1032
|
+
...(options.deviceHash === undefined ? {} : { deviceHash: options.deviceHash }),
|
|
1033
|
+
});
|
|
1034
|
+
if (claims.sessionId !== sessionId || claims.expiresAt !== value.expiresAt) {
|
|
1035
|
+
fail("license_service_invalid_response", "Runtime lease does not match the allocated session");
|
|
1036
|
+
}
|
|
1037
|
+
const manifest = verifyReleaseManifest(value.manifest, this.#releaseTrustedKeys);
|
|
1038
|
+
if (manifest.browserVersion !== browserVersion) {
|
|
1039
|
+
fail("license_service_invalid_response", "Release manifest does not match the runtime lease");
|
|
1040
|
+
}
|
|
1041
|
+
if (!isSdkCompatible(manifest.sdkCompatibility, sdkVersion)) {
|
|
1042
|
+
fail("sdk_version_unsupported", `Browser ${browserVersion} does not support SDK ${sdkVersion}`);
|
|
1043
|
+
}
|
|
1044
|
+
const artifact = selectArtifact(manifest, platform, arch);
|
|
1045
|
+
const serviceOrigin = new URL(this.authorization.serviceUrl).origin;
|
|
1046
|
+
if (new URL(artifact.url).origin !== serviceOrigin) {
|
|
1047
|
+
fail("artifact_origin_invalid", "Authorized artifacts must be served by the license service origin");
|
|
1048
|
+
}
|
|
1049
|
+
const ticket = value.downloadTicket;
|
|
1050
|
+
const artifactUrl = String(ticket?.artifactUrl ?? "");
|
|
1051
|
+
const artifactSha256 = String(ticket?.artifactSha256 ?? "");
|
|
1052
|
+
if (!ticket || typeof ticket.token !== "string" || !ticket.token ||
|
|
1053
|
+
!Number.isSafeInteger(ticket.expiresAt) || ticket.expiresAt !== value.expiresAt ||
|
|
1054
|
+
artifactSha256 !== artifact.sha256 || !artifactUrl ||
|
|
1055
|
+
new URL(artifactUrl).origin !== serviceOrigin) {
|
|
1056
|
+
fail("license_service_invalid_response", "Runtime download ticket response is invalid");
|
|
1057
|
+
}
|
|
1058
|
+
const plan = value.plan;
|
|
1059
|
+
if (!new Set(["free", "basic", "pro", "max", "ultra"]).has(String(plan)) ||
|
|
1060
|
+
!Number.isSafeInteger(value.concurrencyLimit) || !Number.isSafeInteger(value.activeSessions)) {
|
|
1061
|
+
fail("license_service_invalid_response", "Runtime plan response is invalid");
|
|
1062
|
+
}
|
|
1063
|
+
const features = parseResponseFeatures(value.features, claims.features);
|
|
1064
|
+
assertClaimsMatchPlan(claims, plan, Number(value.concurrencyLimit), features);
|
|
1065
|
+
return {
|
|
1066
|
+
schemaVersion: 2,
|
|
1067
|
+
state: value.state,
|
|
1068
|
+
startupId,
|
|
1069
|
+
sessionId,
|
|
1070
|
+
bootstrapToken,
|
|
1071
|
+
activationTicket,
|
|
1072
|
+
...(driverActivationTicket === undefined ? {} : { driverActivationTicket }),
|
|
1073
|
+
heartbeatAfterSeconds: Number(value.heartbeatAfterSeconds),
|
|
1074
|
+
expiresAt: Number(value.expiresAt),
|
|
1075
|
+
plan: plan,
|
|
1076
|
+
features,
|
|
1077
|
+
concurrencyLimit: Number(value.concurrencyLimit),
|
|
1078
|
+
activeSessions: Number(value.activeSessions),
|
|
1079
|
+
browserVersion,
|
|
1080
|
+
...(requestedBrowserVersion === undefined ? {} : { requestedBrowserVersion: String(requestedBrowserVersion) }),
|
|
1081
|
+
...(requestedKernelMajor === undefined ? {} : { requestedKernelMajor }),
|
|
1082
|
+
versionPolicy,
|
|
1083
|
+
selectionReason: selectionReason,
|
|
1084
|
+
...(selectionMode === undefined ? {} : { selectionMode }),
|
|
1085
|
+
availableBrowserVersions: availableBrowserVersions,
|
|
1086
|
+
...(latestAvailableVersion === undefined ? {} : { latestAvailableVersion }),
|
|
1087
|
+
...(updateAvailable === undefined ? {} : { updateAvailable }),
|
|
1088
|
+
...(updateRequired === undefined ? {} : { updateRequired }),
|
|
1089
|
+
updateRights: updateRights,
|
|
1090
|
+
lease,
|
|
1091
|
+
claims,
|
|
1092
|
+
manifest,
|
|
1093
|
+
artifact,
|
|
1094
|
+
platform,
|
|
1095
|
+
arch,
|
|
1096
|
+
...(options.automationBackend === undefined ? {} : { automationBackend: options.automationBackend }),
|
|
1097
|
+
downloadTicket: {
|
|
1098
|
+
token: ticket.token,
|
|
1099
|
+
expiresAt: Number(ticket.expiresAt),
|
|
1100
|
+
artifactSha256,
|
|
1101
|
+
artifactUrl,
|
|
1102
|
+
},
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
async heartbeat(grant) {
|
|
1106
|
+
const value = object(await this.#request("POST", `/v1/licenses/sessions/${encodeURIComponent(grant.sessionId)}/heartbeat`, {
|
|
1107
|
+
authorization: `Session ${grant.sessionToken}`,
|
|
1108
|
+
body: {},
|
|
1109
|
+
}), "license_service_invalid_response");
|
|
1110
|
+
const lease = value.lease;
|
|
1111
|
+
const claims = this.#licenseVerifier.verify(lease, {
|
|
1112
|
+
browserVersion: grant.browserVersion,
|
|
1113
|
+
requiredFeatures: requiredLeaseFeatures(),
|
|
1114
|
+
...(grant.claims.deviceHash === undefined ? {} : { deviceHash: grant.claims.deviceHash }),
|
|
1115
|
+
});
|
|
1116
|
+
if (claims.sessionId !== grant.sessionId || claims.expiresAt !== value.expiresAt) {
|
|
1117
|
+
fail("license_service_invalid_response", "Heartbeat lease does not match the active session");
|
|
1118
|
+
}
|
|
1119
|
+
const plan = value.plan;
|
|
1120
|
+
if (!new Set(["free", "basic", "pro", "max", "ultra"]).has(String(plan)) ||
|
|
1121
|
+
!Number.isSafeInteger(value.concurrencyLimit) || !Number.isSafeInteger(value.activeSessions)) {
|
|
1122
|
+
fail("license_service_invalid_response", "Heartbeat plan response is invalid");
|
|
1123
|
+
}
|
|
1124
|
+
const features = parseResponseFeatures(value.features, claims.features);
|
|
1125
|
+
assertClaimsMatchPlan(claims, plan, Number(value.concurrencyLimit), features);
|
|
1126
|
+
return { expiresAt: Number(value.expiresAt), lease, claims };
|
|
1127
|
+
}
|
|
1128
|
+
async bootstrapHeartbeat(grant) {
|
|
1129
|
+
const value = object(await this.#request("POST", `/v2/runtime/sessions/${encodeURIComponent(grant.sessionId)}/bootstrap-heartbeat`, {
|
|
1130
|
+
authorization: `Bootstrap ${grant.bootstrapToken}`,
|
|
1131
|
+
}), "license_service_invalid_response");
|
|
1132
|
+
return this.#runtimeHeartbeatGrant(value, grant);
|
|
1133
|
+
}
|
|
1134
|
+
async activateRuntimeSession(grant) {
|
|
1135
|
+
const value = object(await this.#request("POST", `/v2/runtime/sessions/${encodeURIComponent(grant.sessionId)}/activate`, {
|
|
1136
|
+
authorization: `Activation ${grant.activationTicket}`,
|
|
1137
|
+
}), "license_service_invalid_response");
|
|
1138
|
+
const heartbeat = this.#runtimeHeartbeatGrant(value, grant);
|
|
1139
|
+
const runtimeToken = String(value.runtimeToken ?? "");
|
|
1140
|
+
if (heartbeat.state !== "active" || !runtimeToken) {
|
|
1141
|
+
fail("license_service_invalid_response", "Runtime activation response is invalid");
|
|
1142
|
+
}
|
|
1143
|
+
return { ...heartbeat, state: "active", runtimeToken };
|
|
1144
|
+
}
|
|
1145
|
+
async runtimeHeartbeat(grant) {
|
|
1146
|
+
const value = object(await this.#request("POST", `/v2/runtime/sessions/${encodeURIComponent(grant.sessionId)}/heartbeat`, {
|
|
1147
|
+
authorization: `Runtime ${grant.runtimeToken}`,
|
|
1148
|
+
}), "license_service_invalid_response");
|
|
1149
|
+
return this.#runtimeHeartbeatGrant(value, grant);
|
|
1150
|
+
}
|
|
1151
|
+
async closeRuntimeSession(grant) {
|
|
1152
|
+
const value = object(await this.#request("POST", `/v2/runtime/sessions/${encodeURIComponent(grant.sessionId)}/close`, {
|
|
1153
|
+
authorization: `Runtime ${grant.runtimeToken}`,
|
|
1154
|
+
}), "license_service_invalid_response");
|
|
1155
|
+
return this.#runtimeHeartbeatGrant(value, grant);
|
|
1156
|
+
}
|
|
1157
|
+
async release(grant) {
|
|
1158
|
+
await this.#request("DELETE", `/v1/licenses/sessions/${encodeURIComponent(grant.sessionId)}`, {
|
|
1159
|
+
authorization: `Session ${grant.sessionToken}`,
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
async releaseRuntimeSession(grant) {
|
|
1163
|
+
if ("runtimeToken" in grant) {
|
|
1164
|
+
await this.#request("DELETE", `/v2/runtime/sessions/${encodeURIComponent(grant.sessionId)}`, {
|
|
1165
|
+
authorization: `Runtime ${grant.runtimeToken}`,
|
|
1166
|
+
});
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
await this.#request("DELETE", `/v2/runtime/sessions/${encodeURIComponent(grant.sessionId)}`, {
|
|
1170
|
+
authorization: `Bootstrap ${grant.bootstrapToken}`,
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
async downloadArtifact(grant) {
|
|
1174
|
+
const response = await this.#fetch(grant.artifact.url, {
|
|
1175
|
+
method: "GET",
|
|
1176
|
+
headers: { authorization: `Session ${grant.sessionToken}` },
|
|
1177
|
+
redirect: "error",
|
|
1178
|
+
});
|
|
1179
|
+
if (!response.ok || !response.body)
|
|
1180
|
+
await this.#throwResponse(response);
|
|
1181
|
+
return response;
|
|
1182
|
+
}
|
|
1183
|
+
async downloadRuntimeArtifact(grant) {
|
|
1184
|
+
const artifactUrl = new URL(grant.downloadTicket.artifactUrl);
|
|
1185
|
+
if (artifactUrl.pathname.startsWith("/v1/releases/artifacts/")) {
|
|
1186
|
+
artifactUrl.pathname = artifactUrl.pathname.replace("/v1/releases/artifacts/", "/v2/runtime/artifacts/");
|
|
1187
|
+
}
|
|
1188
|
+
if (!artifactUrl.pathname.startsWith("/v2/runtime/artifacts/")) {
|
|
1189
|
+
fail("license_service_invalid_response", "Runtime artifact URL is invalid");
|
|
1190
|
+
}
|
|
1191
|
+
const response = await this.#fetch(artifactUrl.toString(), {
|
|
1192
|
+
method: "GET",
|
|
1193
|
+
headers: { authorization: `Download ${grant.downloadTicket.token}` },
|
|
1194
|
+
redirect: "error",
|
|
1195
|
+
});
|
|
1196
|
+
if (!response.ok || !response.body)
|
|
1197
|
+
await this.#throwResponse(response);
|
|
1198
|
+
return response;
|
|
1199
|
+
}
|
|
1200
|
+
#runtimeHeartbeatGrant(value, grant) {
|
|
1201
|
+
if (value.schemaVersion !== 2 || value.startupId !== grant.startupId ||
|
|
1202
|
+
value.sessionId !== grant.sessionId || !Number.isSafeInteger(value.expiresAt) ||
|
|
1203
|
+
!Number.isSafeInteger(value.heartbeatAfterSeconds)) {
|
|
1204
|
+
fail("license_service_invalid_response", "Runtime heartbeat response is invalid");
|
|
1205
|
+
}
|
|
1206
|
+
const state = String(value.state ?? "");
|
|
1207
|
+
if (state !== "reserved" && state !== "active" && state !== "closing") {
|
|
1208
|
+
fail("license_service_invalid_response", "Runtime session state is invalid");
|
|
1209
|
+
}
|
|
1210
|
+
const lease = value.lease;
|
|
1211
|
+
const claims = this.#licenseVerifier.verify(lease, {
|
|
1212
|
+
browserVersion: grant.browserVersion,
|
|
1213
|
+
requiredFeatures: requiredLeaseFeatures(grant.automationBackend),
|
|
1214
|
+
...(grant.claims.deviceHash === undefined ? {} : { deviceHash: grant.claims.deviceHash }),
|
|
1215
|
+
});
|
|
1216
|
+
if (claims.sessionId !== grant.sessionId || claims.expiresAt !== value.expiresAt) {
|
|
1217
|
+
fail("license_service_invalid_response", "Runtime heartbeat lease does not match the active session");
|
|
1218
|
+
}
|
|
1219
|
+
const plan = value.plan;
|
|
1220
|
+
if (!new Set(["free", "basic", "pro", "max", "ultra"]).has(String(plan)) ||
|
|
1221
|
+
!Number.isSafeInteger(value.concurrencyLimit) || !Number.isSafeInteger(value.activeSessions)) {
|
|
1222
|
+
fail("license_service_invalid_response", "Runtime heartbeat plan response is invalid");
|
|
1223
|
+
}
|
|
1224
|
+
const features = parseResponseFeatures(value.features, claims.features);
|
|
1225
|
+
assertClaimsMatchPlan(claims, plan, Number(value.concurrencyLimit), features);
|
|
1226
|
+
return {
|
|
1227
|
+
schemaVersion: 2,
|
|
1228
|
+
state: state,
|
|
1229
|
+
startupId: grant.startupId,
|
|
1230
|
+
sessionId: grant.sessionId,
|
|
1231
|
+
heartbeatAfterSeconds: Number(value.heartbeatAfterSeconds),
|
|
1232
|
+
expiresAt: Number(value.expiresAt),
|
|
1233
|
+
plan: plan,
|
|
1234
|
+
features,
|
|
1235
|
+
concurrencyLimit: Number(value.concurrencyLimit),
|
|
1236
|
+
activeSessions: Number(value.activeSessions),
|
|
1237
|
+
browserVersion: grant.browserVersion,
|
|
1238
|
+
...(grant.automationBackend === undefined ? {} : { automationBackend: grant.automationBackend }),
|
|
1239
|
+
lease,
|
|
1240
|
+
claims,
|
|
1241
|
+
};
|
|
1242
|
+
}
|
|
1243
|
+
async #request(method, path, options) {
|
|
1244
|
+
const response = await this.#fetch(`${this.authorization.serviceUrl}${path}`, {
|
|
1245
|
+
method,
|
|
1246
|
+
headers: {
|
|
1247
|
+
authorization: options.authorization,
|
|
1248
|
+
...(options.body === undefined ? {} : { "content-type": "application/json" }),
|
|
1249
|
+
},
|
|
1250
|
+
...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
|
|
1251
|
+
redirect: "error",
|
|
1252
|
+
});
|
|
1253
|
+
if (!response.ok)
|
|
1254
|
+
await this.#throwResponse(response);
|
|
1255
|
+
if (response.status === 204)
|
|
1256
|
+
return {};
|
|
1257
|
+
try {
|
|
1258
|
+
return await response.json();
|
|
1259
|
+
}
|
|
1260
|
+
catch {
|
|
1261
|
+
fail("license_service_invalid_response", "License service returned invalid JSON", response.status);
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
async #throwResponse(response) {
|
|
1265
|
+
const payload = await response.json().catch(() => null);
|
|
1266
|
+
const error = payload?.error;
|
|
1267
|
+
const code = safeRemoteErrorCode(error?.code);
|
|
1268
|
+
fail(code, remoteErrorMessage(response.status, code), response.status, safeErrorDetails(error));
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
//# sourceMappingURL=service.js.map
|