@vlydev/cs2-masked-inspect 1.0.0 → 1.0.7
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/.github/workflows/release.yml +30 -0
- package/README.md +18 -0
- package/package.json +12 -2
- package/src/InspectLink.js +27 -6
- package/src/ItemPreviewData.js +2 -2
- package/src/Sticker.js +3 -0
- package/src/proto/reader.js +4 -0
- package/tests/inspect_link.test.js +88 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
publish-npm:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
environment: npm
|
|
11
|
+
permissions:
|
|
12
|
+
id-token: write # required for npm trusted publishing (OIDC)
|
|
13
|
+
contents: read
|
|
14
|
+
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
|
|
18
|
+
- name: Setup Node.js
|
|
19
|
+
uses: actions/setup-node@v4
|
|
20
|
+
with:
|
|
21
|
+
node-version: "24"
|
|
22
|
+
registry-url: "https://registry.npmjs.org"
|
|
23
|
+
|
|
24
|
+
- name: Set version from tag
|
|
25
|
+
run: |
|
|
26
|
+
VERSION="${GITHUB_REF_NAME#v}"
|
|
27
|
+
npm version "$VERSION" --no-git-tag-version
|
|
28
|
+
|
|
29
|
+
- name: Publish to npm
|
|
30
|
+
run: npm publish --access public --provenance
|
package/README.md
CHANGED
|
@@ -98,6 +98,24 @@ InspectLink.isClassic(classicUrl); // true
|
|
|
98
98
|
|
|
99
99
|
---
|
|
100
100
|
|
|
101
|
+
## Validation rules
|
|
102
|
+
|
|
103
|
+
`deserialize()` enforces:
|
|
104
|
+
|
|
105
|
+
| Rule | Limit | Error |
|
|
106
|
+
|------|-------|-------|
|
|
107
|
+
| Hex payload length | max 4,096 characters | `RangeError` |
|
|
108
|
+
| Protobuf field count | max 100 per message | `RangeError` |
|
|
109
|
+
|
|
110
|
+
`serialize()` enforces:
|
|
111
|
+
|
|
112
|
+
| Field | Constraint | Error |
|
|
113
|
+
|-------|-----------|-------|
|
|
114
|
+
| `paintWear` | `[0.0, 1.0]` | `RangeError` |
|
|
115
|
+
| `customName` | max 100 characters | `RangeError` |
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
101
119
|
## How the format works
|
|
102
120
|
|
|
103
121
|
Three URL formats are handled:
|
package/package.json
CHANGED
|
@@ -1,17 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vlydev/cs2-masked-inspect",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"description": "Offline decoder/encoder for CS2 masked inspect URLs — pure JavaScript, no dependencies",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"test": "node --test tests/inspect_link.test.js"
|
|
8
8
|
},
|
|
9
|
-
"keywords": [
|
|
9
|
+
"keywords": [
|
|
10
|
+
"cs2",
|
|
11
|
+
"csgo",
|
|
12
|
+
"inspect",
|
|
13
|
+
"protobuf",
|
|
14
|
+
"steam"
|
|
15
|
+
],
|
|
10
16
|
"author": {
|
|
11
17
|
"name": "VlyDev",
|
|
12
18
|
"email": "vladdnepr1989@gmail.com",
|
|
13
19
|
"url": "https://github.com/vlydev"
|
|
14
20
|
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "https://github.com/vlydev/cs2-masked-inspect-js"
|
|
24
|
+
},
|
|
15
25
|
"license": "MIT",
|
|
16
26
|
"engines": {
|
|
17
27
|
"node": ">=18.0.0"
|
package/src/InspectLink.js
CHANGED
|
@@ -102,11 +102,14 @@ function extractHex(input) {
|
|
|
102
102
|
const mh = stripped.match(HYBRID_URL_RE);
|
|
103
103
|
if (mh && /[A-Fa-f]/.test(mh[1])) return mh[1];
|
|
104
104
|
|
|
105
|
-
// Classic/market URL: A<hex> preceded by %20, space, or + (A is a prefix marker, not hex)
|
|
105
|
+
// Classic/market URL: A<hex> preceded by %20, space, or + (A is a prefix marker, not hex).
|
|
106
|
+
// If stripping A yields odd-length hex, A is actually the first byte of the payload —
|
|
107
|
+
// fall through to the pure-masked check below which captures it with A included.
|
|
106
108
|
const m = stripped.match(INSPECT_URL_RE);
|
|
107
|
-
if (m) return m[1];
|
|
109
|
+
if (m && m[1].length % 2 === 0) return m[1];
|
|
108
110
|
|
|
109
|
-
// Pure masked format: csgo_econ_action_preview%20<hexblob> (no S/A/M prefix)
|
|
111
|
+
// Pure masked format: csgo_econ_action_preview%20<hexblob> (no S/A/M prefix).
|
|
112
|
+
// Also handles payloads whose first hex character happens to be A.
|
|
110
113
|
const mm = stripped.match(/csgo_econ_action_preview(?:%20|\s|\+)([0-9A-Fa-f]{10,})$/i);
|
|
111
114
|
if (mm) return mm[1];
|
|
112
115
|
|
|
@@ -131,6 +134,7 @@ function encodeSticker(s) {
|
|
|
131
134
|
if (s.offsetY !== null) w.writeFloat32Fixed(8, s.offsetY);
|
|
132
135
|
if (s.offsetZ !== null) w.writeFloat32Fixed(9, s.offsetZ);
|
|
133
136
|
w.writeUint32(10, s.pattern);
|
|
137
|
+
if (s.highlightReel != null) w.writeUint32(11, s.highlightReel);
|
|
134
138
|
return w.toBytes();
|
|
135
139
|
}
|
|
136
140
|
|
|
@@ -150,7 +154,8 @@ function decodeSticker(data) {
|
|
|
150
154
|
case 7: s.offsetX = f.value.readFloatLE(0); break;
|
|
151
155
|
case 8: s.offsetY = f.value.readFloatLE(0); break;
|
|
152
156
|
case 9: s.offsetZ = f.value.readFloatLE(0); break;
|
|
153
|
-
case 10: s.pattern
|
|
157
|
+
case 10: s.pattern = Number(f.value); break;
|
|
158
|
+
case 11: s.highlightReel = Number(f.value); break;
|
|
154
159
|
default: break;
|
|
155
160
|
}
|
|
156
161
|
}
|
|
@@ -173,8 +178,9 @@ function encodeItem(item) {
|
|
|
173
178
|
w.writeUint32(6, item.quality);
|
|
174
179
|
|
|
175
180
|
// paintwear: float32 reinterpreted as uint32 varint
|
|
176
|
-
|
|
177
|
-
|
|
181
|
+
if (item.paintWear != null) {
|
|
182
|
+
w.writeUint32(7, float32ToUint32(item.paintWear));
|
|
183
|
+
}
|
|
178
184
|
|
|
179
185
|
w.writeUint32(8, item.paintSeed);
|
|
180
186
|
w.writeUint32(9, item.killEaterScoreType);
|
|
@@ -249,6 +255,16 @@ class InspectLink {
|
|
|
249
255
|
* @returns {string} uppercase hex string
|
|
250
256
|
*/
|
|
251
257
|
static serialize(data) {
|
|
258
|
+
if (data.paintWear != null && (data.paintWear < 0.0 || data.paintWear > 1.0)) {
|
|
259
|
+
throw new RangeError(
|
|
260
|
+
`paintwear must be in [0.0, 1.0], got ${data.paintWear}`,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
if (data.customName != null && data.customName.length > 100) {
|
|
264
|
+
throw new RangeError(
|
|
265
|
+
`customname must not exceed 100 characters, got ${data.customName.length}`,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
252
268
|
const protoBytes = encodeItem(data);
|
|
253
269
|
const buffer = Buffer.concat([Buffer.from([0x00]), protoBytes]);
|
|
254
270
|
const checksum = computeChecksum(buffer, protoBytes.length);
|
|
@@ -291,6 +307,11 @@ class InspectLink {
|
|
|
291
307
|
*/
|
|
292
308
|
static deserialize(input) {
|
|
293
309
|
const hex = extractHex(input);
|
|
310
|
+
if (hex.length > 4096) {
|
|
311
|
+
throw new RangeError(
|
|
312
|
+
`Payload too long (max 4096 hex chars): "${input.slice(0, 64)}..."`,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
294
315
|
const raw = Buffer.from(hex, 'hex');
|
|
295
316
|
|
|
296
317
|
if (raw.length < 6) {
|
package/src/ItemPreviewData.js
CHANGED
|
@@ -21,7 +21,7 @@ class ItemPreviewData {
|
|
|
21
21
|
* @param {number} [opts.paintIndex=0]
|
|
22
22
|
* @param {number} [opts.rarity=0]
|
|
23
23
|
* @param {number} [opts.quality=0]
|
|
24
|
-
* @param {number}
|
|
24
|
+
* @param {number|null} [opts.paintWear=null]
|
|
25
25
|
* @param {number} [opts.paintSeed=0]
|
|
26
26
|
* @param {number} [opts.killEaterScoreType=0]
|
|
27
27
|
* @param {number} [opts.killEaterValue=0]
|
|
@@ -43,7 +43,7 @@ class ItemPreviewData {
|
|
|
43
43
|
paintIndex = 0,
|
|
44
44
|
rarity = 0,
|
|
45
45
|
quality = 0,
|
|
46
|
-
paintWear =
|
|
46
|
+
paintWear = null,
|
|
47
47
|
paintSeed = 0,
|
|
48
48
|
killEaterScoreType = 0,
|
|
49
49
|
killEaterValue = 0,
|
package/src/Sticker.js
CHANGED
|
@@ -19,6 +19,7 @@ class Sticker {
|
|
|
19
19
|
* @param {number|null} [opts.offsetY=null]
|
|
20
20
|
* @param {number|null} [opts.offsetZ=null]
|
|
21
21
|
* @param {number} [opts.pattern=0]
|
|
22
|
+
* @param {number|null} [opts.highlightReel=null]
|
|
22
23
|
*/
|
|
23
24
|
constructor({
|
|
24
25
|
slot = 0,
|
|
@@ -31,6 +32,7 @@ class Sticker {
|
|
|
31
32
|
offsetY = null,
|
|
32
33
|
offsetZ = null,
|
|
33
34
|
pattern = 0,
|
|
35
|
+
highlightReel = null,
|
|
34
36
|
} = {}) {
|
|
35
37
|
this.slot = slot;
|
|
36
38
|
this.stickerId = stickerId;
|
|
@@ -42,6 +44,7 @@ class Sticker {
|
|
|
42
44
|
this.offsetY = offsetY;
|
|
43
45
|
this.offsetZ = offsetZ;
|
|
44
46
|
this.pattern = pattern;
|
|
47
|
+
this.highlightReel = highlightReel;
|
|
45
48
|
}
|
|
46
49
|
}
|
|
47
50
|
|
package/src/proto/reader.js
CHANGED
|
@@ -94,8 +94,12 @@ class ProtoReader {
|
|
|
94
94
|
*/
|
|
95
95
|
readAllFields() {
|
|
96
96
|
const fields = [];
|
|
97
|
+
let fieldCount = 0;
|
|
97
98
|
|
|
98
99
|
while (this.remaining() > 0) {
|
|
100
|
+
if (++fieldCount > 100) {
|
|
101
|
+
throw new RangeError('Protobuf field count exceeds limit of 100');
|
|
102
|
+
}
|
|
99
103
|
const [fieldNum, wireType] = this.readTag();
|
|
100
104
|
|
|
101
105
|
let value;
|
|
@@ -359,3 +359,91 @@ describe('checksum', () => {
|
|
|
359
359
|
assert.equal(InspectLink.serialize(data), TOOL_HEX);
|
|
360
360
|
});
|
|
361
361
|
});
|
|
362
|
+
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
// Defensive validation tests
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
describe('deserialize — payload too long', () => {
|
|
368
|
+
test('throws RangeError for hex payload exceeding 4096 chars (4098 chars)', () => {
|
|
369
|
+
const longHex = '00'.repeat(2049); // 4098 hex chars
|
|
370
|
+
assert.throws(() => InspectLink.deserialize(longHex), RangeError);
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
describe('serialize — paintwear validation', () => {
|
|
375
|
+
test('throws RangeError when paintwear > 1.0', () => {
|
|
376
|
+
const data = new ItemPreviewData({ paintWear: 1.1 });
|
|
377
|
+
assert.throws(() => InspectLink.serialize(data), RangeError);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test('throws RangeError when paintwear < 0.0', () => {
|
|
381
|
+
const data = new ItemPreviewData({ paintWear: -0.1 });
|
|
382
|
+
assert.throws(() => InspectLink.serialize(data), RangeError);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test('does not throw when paintwear = 0.0 (boundary)', () => {
|
|
386
|
+
const data = new ItemPreviewData({ paintWear: 0.0 });
|
|
387
|
+
assert.doesNotThrow(() => InspectLink.serialize(data));
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test('does not throw when paintwear = 1.0 (boundary)', () => {
|
|
391
|
+
const data = new ItemPreviewData({ paintWear: 1.0 });
|
|
392
|
+
assert.doesNotThrow(() => InspectLink.serialize(data));
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
describe('serialize — customname validation', () => {
|
|
397
|
+
test('throws RangeError when customName is 101 characters', () => {
|
|
398
|
+
const data = new ItemPreviewData({ customName: 'a'.repeat(101) });
|
|
399
|
+
assert.throws(() => InspectLink.serialize(data), RangeError);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test('does not throw when customName is exactly 100 characters', () => {
|
|
403
|
+
const data = new ItemPreviewData({ customName: 'a'.repeat(100) });
|
|
404
|
+
assert.doesNotThrow(() => InspectLink.serialize(data));
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
// CSFloat/gen.test.ts vectors
|
|
410
|
+
// ---------------------------------------------------------------------------
|
|
411
|
+
|
|
412
|
+
const CSFLOAT_A = '00180720DA03280638FBEE88F90340B2026BC03C96';
|
|
413
|
+
const CSFLOAT_B = '00180720C80A280638A4E1F5FB03409A0562040800104C62040801104C62040802104C62040803104C6D4F5E30';
|
|
414
|
+
const CSFLOAT_C = 'A2B2A2BA69A882A28AA192AECAA2D2B700A3A5AAA2B286FA7BA0D684BE72';
|
|
415
|
+
|
|
416
|
+
describe('CSFloat test vectors', () => {
|
|
417
|
+
test('VectorA: defindex', () => assert.equal(InspectLink.deserialize(CSFLOAT_A).defIndex, 7));
|
|
418
|
+
test('VectorA: paintindex', () => assert.equal(InspectLink.deserialize(CSFLOAT_A).paintIndex, 474));
|
|
419
|
+
test('VectorA: paintseed', () => assert.equal(InspectLink.deserialize(CSFLOAT_A).paintSeed, 306));
|
|
420
|
+
test('VectorA: rarity', () => assert.equal(InspectLink.deserialize(CSFLOAT_A).rarity, 6));
|
|
421
|
+
test('VectorA: paintwear not null', () => assert.notEqual(InspectLink.deserialize(CSFLOAT_A).paintWear, null));
|
|
422
|
+
test('VectorA: paintwear approx', () => assert.ok(Math.abs(InspectLink.deserialize(CSFLOAT_A).paintWear - 0.6337) < 0.001));
|
|
423
|
+
|
|
424
|
+
test('VectorB: 4 stickers', () => assert.equal(InspectLink.deserialize(CSFLOAT_B).stickers.length, 4));
|
|
425
|
+
test('VectorB: sticker ids all 76', () => {
|
|
426
|
+
InspectLink.deserialize(CSFLOAT_B).stickers.forEach(s => assert.equal(s.stickerId, 76));
|
|
427
|
+
});
|
|
428
|
+
test('VectorB: paintindex', () => assert.equal(InspectLink.deserialize(CSFLOAT_B).paintIndex, 1352));
|
|
429
|
+
test('VectorB: paintwear approx 0.99', () => assert.ok(Math.abs(InspectLink.deserialize(CSFLOAT_B).paintWear - 0.99) < 0.01));
|
|
430
|
+
|
|
431
|
+
test('VectorC: defindex', () => assert.equal(InspectLink.deserialize(CSFLOAT_C).defIndex, 1355));
|
|
432
|
+
test('VectorC: quality', () => assert.equal(InspectLink.deserialize(CSFLOAT_C).quality, 12));
|
|
433
|
+
test('VectorC: keychain count', () => assert.equal(InspectLink.deserialize(CSFLOAT_C).keychains.length, 1));
|
|
434
|
+
test('VectorC: keychain highlightReel', () => assert.equal(InspectLink.deserialize(CSFLOAT_C).keychains[0].highlightReel, 345));
|
|
435
|
+
test('VectorC: no paintwear', () => assert.equal(InspectLink.deserialize(CSFLOAT_C).paintWear, null));
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
describe('Roundtrip: highlight_reel and nullable paintWear', () => {
|
|
439
|
+
test('highlightReel roundtrip', () => {
|
|
440
|
+
const data = new ItemPreviewData({ defIndex: 7, keychains: [new Sticker({ slot: 0, stickerId: 36, highlightReel: 345 })] });
|
|
441
|
+
const result = InspectLink.deserialize(InspectLink.serialize(data));
|
|
442
|
+
assert.equal(result.keychains[0].highlightReel, 345);
|
|
443
|
+
});
|
|
444
|
+
test('null paintWear roundtrip', () => {
|
|
445
|
+
const data = new ItemPreviewData({ defIndex: 7, paintWear: null });
|
|
446
|
+
const result = InspectLink.deserialize(InspectLink.serialize(data));
|
|
447
|
+
assert.equal(result.paintWear, null);
|
|
448
|
+
});
|
|
449
|
+
});
|