@magield/collectors 0.0.0-stage → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/LICENSE +27 -0
  2. package/dist/behavior/recorder.d.ts +33 -0
  3. package/dist/behavior/recorder.js +303 -0
  4. package/dist/hash.d.ts +15 -0
  5. package/dist/hash.js +27 -0
  6. package/dist/index.d.ts +7 -0
  7. package/dist/index.js +7 -0
  8. package/dist/native.d.ts +39 -0
  9. package/dist/native.js +142 -0
  10. package/dist/registry.d.ts +57 -0
  11. package/dist/registry.js +108 -0
  12. package/dist/run.d.ts +10 -0
  13. package/dist/run.js +128 -0
  14. package/dist/signals/adblock.d.ts +2 -0
  15. package/dist/signals/adblock.js +135 -0
  16. package/dist/signals/apps.d.ts +2 -0
  17. package/dist/signals/apps.js +191 -0
  18. package/dist/signals/audio.d.ts +16 -0
  19. package/dist/signals/audio.js +63 -0
  20. package/dist/signals/automation.d.ts +2 -0
  21. package/dist/signals/automation.js +346 -0
  22. package/dist/signals/behavior.d.ts +17 -0
  23. package/dist/signals/behavior.js +31 -0
  24. package/dist/signals/capabilities.d.ts +2 -0
  25. package/dist/signals/capabilities.js +235 -0
  26. package/dist/signals/devices.d.ts +2 -0
  27. package/dist/signals/devices.js +119 -0
  28. package/dist/signals/display.d.ts +2 -0
  29. package/dist/signals/display.js +101 -0
  30. package/dist/signals/errors.d.ts +2 -0
  31. package/dist/signals/errors.js +84 -0
  32. package/dist/signals/features.d.ts +2 -0
  33. package/dist/signals/features.js +951 -0
  34. package/dist/signals/fonts.d.ts +2 -0
  35. package/dist/signals/fonts.js +128 -0
  36. package/dist/signals/integrity.d.ts +2 -0
  37. package/dist/signals/integrity.js +216 -0
  38. package/dist/signals/math.d.ts +2 -0
  39. package/dist/signals/math.js +43 -0
  40. package/dist/signals/navigator.d.ts +10 -0
  41. package/dist/signals/navigator.js +81 -0
  42. package/dist/signals/pointer.d.ts +2 -0
  43. package/dist/signals/pointer.js +93 -0
  44. package/dist/signals/render.d.ts +12 -0
  45. package/dist/signals/render.js +214 -0
  46. package/dist/signals/screen.d.ts +9 -0
  47. package/dist/signals/screen.js +80 -0
  48. package/dist/signals/storage.d.ts +33 -0
  49. package/dist/signals/storage.js +100 -0
  50. package/dist/signals/timezone.d.ts +9 -0
  51. package/dist/signals/timezone.js +63 -0
  52. package/dist/signals/webrtc.d.ts +2 -0
  53. package/dist/signals/webrtc.js +168 -0
  54. package/dist/stable-core.d.ts +128 -0
  55. package/dist/stable-core.js +187 -0
  56. package/dist/types.d.ts +54 -0
  57. package/dist/types.js +15 -0
  58. package/package.json +31 -3
  59. package/README.md +0 -3
@@ -0,0 +1,168 @@
1
+ import { hash } from '../hash.js';
2
+ /**
3
+ * What the WebRTC stack says about the network beneath the browser.
4
+ *
5
+ * In `INVASIVE_COLLECTORS` rather than the default set. No permission prompt is
6
+ * involved — candidate gathering needs none — but this reads the shape of
7
+ * somebody's home network, which is about the person rather than the browser.
8
+ *
9
+ * ## What it is for
10
+ *
11
+ * A candidate is a route the browser would accept media on, and the set of them
12
+ * describes the path out:
13
+ *
14
+ * `host` an address on the machine itself
15
+ * `srflx` the address a STUN server saw, which is the real public egress
16
+ * `relay` a TURN relay, meaning nothing direct worked
17
+ *
18
+ * The `srflx` address is the one that matters, and it is the reason this is
19
+ * worth collecting: it is observed by a third party rather than asserted by the
20
+ * client, so a proxy that rewrites HTTP headers does not move it. A request
21
+ * whose HTTP address is residential while its `srflx` address belongs to a
22
+ * datacentre is a contradiction the header cannot produce on its own.
23
+ *
24
+ * ## What it mostly gets, in practice
25
+ *
26
+ * Browsers have spent years closing this. Chrome replaces host candidates with
27
+ * an mDNS name — a random `.local` hostname regenerated per origin — so the
28
+ * classic "local IP leak" is gone by default. Firefox does the same under
29
+ * `resistFingerprinting`.
30
+ *
31
+ * That is recorded rather than worked around: `webrtc.mdns` says the
32
+ * obfuscation is active, which is itself a fact about the browser, and no local
33
+ * address is reported when there is none to report. What survives is the
34
+ * candidate *shape* — how many of each type, whether a relay was needed,
35
+ * whether gathering completed at all — and the public address, which is not
36
+ * obfuscated because a STUN server has to know it for the protocol to work.
37
+ */
38
+ /**
39
+ * A public STUN server, used only to learn the reflexive address.
40
+ *
41
+ * This is an outbound request from the visitor's browser to a third party, and
42
+ * it is the reason this collector is opt-in even though it prompts for nothing.
43
+ * Google's is used because it is the one every WebRTC application already
44
+ * contacts, so it adds no new party to a connection the browser makes anyway —
45
+ * but a deployment that would rather not can run its own and change this line.
46
+ */
47
+ const STUN = 'stun:stun.l.google.com:19302';
48
+ /** Short: gathering usually settles in well under this, and the wave has a budget. */
49
+ const GATHER_TIMEOUT_MS = 700;
50
+ export const webrtcCollector = {
51
+ id: 'webrtc',
52
+ wave: 'deep',
53
+ optional: true,
54
+ async collect({ window }) {
55
+ const out = {};
56
+ const Ctor = window.RTCPeerConnection ??
57
+ window.webkitRTCPeerConnection;
58
+ if (Ctor === undefined) {
59
+ // Present and null: the probe ran and the API is absent, which is a
60
+ // different fact from the collector never running.
61
+ out['webrtc.supported'] = false;
62
+ out['webrtc.candidates'] = null;
63
+ return out;
64
+ }
65
+ out['webrtc.supported'] = true;
66
+ let connection;
67
+ try {
68
+ connection = new Ctor({ iceServers: [{ urls: STUN }] });
69
+ // A data channel is what makes the browser gather at all. Nothing is
70
+ // sent on it and no media device is touched.
71
+ connection.createDataChannel('probe');
72
+ const candidates = [];
73
+ const gathered = new Promise((resolve) => {
74
+ let settled = false;
75
+ const finish = () => {
76
+ if (settled)
77
+ return;
78
+ settled = true;
79
+ resolve();
80
+ };
81
+ connection?.addEventListener('icecandidate', (event) => {
82
+ // A null candidate is the end-of-gathering marker, not a failure.
83
+ if (event.candidate === null)
84
+ return finish();
85
+ if (event.candidate.candidate !== '')
86
+ candidates.push(event.candidate.candidate);
87
+ });
88
+ setTimeout(finish, GATHER_TIMEOUT_MS);
89
+ });
90
+ const offer = await connection.createOffer();
91
+ await connection.setLocalDescription(offer);
92
+ await gathered;
93
+ summarise(candidates, out);
94
+ }
95
+ catch {
96
+ out['webrtc.candidates'] = null;
97
+ }
98
+ finally {
99
+ try {
100
+ connection?.close();
101
+ }
102
+ catch {
103
+ // A connection that will not close is not worth a thrown signal.
104
+ }
105
+ }
106
+ return out;
107
+ },
108
+ };
109
+ /** IPv4 and IPv6 both, since a candidate line carries either. */
110
+ const ADDRESS = /(?:\d{1,3}\.){3}\d{1,3}|(?:[0-9a-f]{0,4}:){2,7}[0-9a-f]{0,4}/i;
111
+ function summarise(candidates, out) {
112
+ out['webrtc.candidates'] = candidates.length;
113
+ if (candidates.length === 0) {
114
+ // Gathering produced nothing. Common in containers with no usable
115
+ // interface, and common behind a firewall that drops STUN — so it is
116
+ // reported as a count rather than inferred into a verdict.
117
+ out['webrtc.mdns'] = null;
118
+ out['webrtc.publicIp'] = null;
119
+ return;
120
+ }
121
+ let host = 0;
122
+ let srflx = 0;
123
+ let relay = 0;
124
+ let mdns = 0;
125
+ const publicAddresses = new Set();
126
+ const localAddresses = new Set();
127
+ for (const line of candidates) {
128
+ const type = /\btyp\s+(\w+)/.exec(line)?.[1] ?? '';
129
+ if (type === 'host')
130
+ host++;
131
+ else if (type === 'srflx')
132
+ srflx++;
133
+ else if (type === 'relay')
134
+ relay++;
135
+ // Chrome substitutes a per-origin random hostname for host candidates, so
136
+ // the address field reads `<uuid>.local` rather than an address.
137
+ if (line.includes('.local')) {
138
+ mdns++;
139
+ continue;
140
+ }
141
+ const address = ADDRESS.exec(line)?.[0];
142
+ if (address === undefined)
143
+ continue;
144
+ if (type === 'host')
145
+ localAddresses.add(address);
146
+ else if (type === 'srflx' || type === 'relay')
147
+ publicAddresses.add(address);
148
+ }
149
+ out['webrtc.host'] = host;
150
+ out['webrtc.srflx'] = srflx;
151
+ out['webrtc.relay'] = relay;
152
+ // True when the browser is obfuscating host candidates, which is the default
153
+ // on current Chrome. A fact about the browser, not a failure of the probe.
154
+ out['webrtc.mdns'] = mdns > 0;
155
+ // The reflexive address: observed by a third party rather than asserted by
156
+ // the client, which is what makes it worth comparing against the HTTP one.
157
+ const [first] = [...publicAddresses].sort();
158
+ out['webrtc.publicIp'] = first ?? null;
159
+ // Local addresses are hashed rather than reported. The count and stability
160
+ // are what a check would read — whether the machine has one interface or
161
+ // twelve, and whether that changed — and the addresses themselves describe
162
+ // somebody's home network without adding anything to that.
163
+ out['webrtc.localCount'] = localAddresses.size;
164
+ out['webrtc.localHash'] = localAddresses.size === 0 ? null : hash([...localAddresses].sort().join(','));
165
+ // No direct route was usable. Ordinary on a locked-down corporate network,
166
+ // and also what a relayed or heavily proxied connection looks like.
167
+ out['webrtc.relayOnly'] = relay > 0 && srflx === 0;
168
+ }
@@ -0,0 +1,128 @@
1
+ import type { SignalValue } from './types.js';
2
+ /**
3
+ * The stable core: what a device still looks like after it clears storage.
4
+ *
5
+ * The device id lives in cookies, localStorage and IndexedDB, so it is gone the
6
+ * moment someone opens a private window or clears site data — and with it goes
7
+ * every limit keyed on the device, including the credential-stuffing defence.
8
+ * That is not a bug in the storage layer; it is what private browsing *is*.
9
+ *
10
+ * The answer is a second, weaker identifier built only from signals that do not
11
+ * depend on storage at all: the GPU, the rendering stack, the CPU class, the
12
+ * screen. Those are the same in a private window as outside one.
13
+ *
14
+ * ## What is deliberately excluded
15
+ *
16
+ * Anything that changes on its own. A browser version, a window size, a
17
+ * connection type — include those and the core shifts under a returning user,
18
+ * which is the failure that makes a fingerprint useless: it stops recognising
19
+ * the people it is supposed to recognise.
20
+ *
21
+ * ## The privacy tension, stated plainly
22
+ *
23
+ * This links a private window to ordinary browsing on the same machine. Someone
24
+ * using a private window is often specifically trying to prevent that. There is
25
+ * no version of this that is both effective against storage clearing and
26
+ * respectful of the intent behind it — the two are the same act.
27
+ *
28
+ * So it is: hashed with the tenant salt before it is stored, never shared
29
+ * between tenants, suppressed entirely under `consent: 'deferred'`, and
30
+ * individually disableable. And it is weaker evidence than a real device id by
31
+ * design — enough to notice one device trying forty accounts, not enough to
32
+ * treat as identity.
33
+ */
34
+ /**
35
+ * Signals that survive a storage wipe and do not drift.
36
+ *
37
+ * Ordered for readability; the hash sorts them.
38
+ */
39
+ declare const STABLE_KEYS: readonly ["webgl.renderer", "webgl.vendor", "webgl.parametersHash", "webgl.fragmentHighFloat", "math.quirksHash", "err.nullAccessMessage", "screen.colorDepth", "screen.devicePixelRatio", "nav.hardwareConcurrency", "nav.deviceMemory", "nav.maxTouchPoints", "nav.platform", "tz.timezone"];
40
+ /**
41
+ * Keys left out, with the reason — so the next person to add one has to argue
42
+ * with something.
43
+ *
44
+ * nav.userAgent moves on every browser update
45
+ * features.hash moves on every browser update
46
+ * screen.innerWidth moves when the window is resized
47
+ * screen.chromeHeight needs focus, and moves with the toolbar
48
+ * nav.languages a user can change it, and some browsers reorder it
49
+ * canvas.stable a property of the browser's privacy settings, not the device
50
+ * nav.connectionType changes with the network, which is the point of it
51
+ *
52
+ * The next three were in the core until a measurement removed them. Safari 26,
53
+ * same machine, normal versus private browsing:
54
+ *
55
+ * canvas.hash 9cabb395 -> ec0c368d
56
+ * screen.width 1728 -> 1340
57
+ * screen.height 1117 -> 823
58
+ *
59
+ * Everything else was identical, so a core without these three would have
60
+ * matched — and with them it could not.
61
+ *
62
+ * canvas.hash Safari's fingerprinting protection seeds canvas noise **per
63
+ * session**. Two renders inside one session agree, so
64
+ * `canvas.stable` reports true and the noise check below does
65
+ * not fire. There is no in-session test that detects this;
66
+ * the only reliable answer is to keep rendered output out of
67
+ * a core that has to survive privacy modes.
68
+ *
69
+ * screen.width Safari private reports the *viewport* as the screen: 1340x823
70
+ * screen.height were exactly innerWidth and innerHeight. So these are not a
71
+ * different screen, they are a spoof — and one that moves
72
+ * again whenever the window is resized.
73
+ */
74
+ export declare const EXCLUDED_FROM_CORE: readonly ["nav.userAgent", "features.hash", "screen.innerWidth", "screen.chromeHeight", "nav.languages", "canvas.stable", "nav.connectionType", "canvas.hash", "screen.width", "screen.height"];
75
+ export interface StableCore {
76
+ readonly hash: string;
77
+ /** How many of the stable keys were actually present. */
78
+ readonly present: number;
79
+ readonly total: number;
80
+ /** The exact values that went into the hash, so two captures can be diffed. */
81
+ readonly parts: Readonly<Record<string, SignalValue>>;
82
+ /** A randomised signal was detected and left out, lowering the core's entropy. */
83
+ readonly noiseExcluded: boolean;
84
+ /**
85
+ * Below this, the hash is too thin to relink on — a handful of common values
86
+ * would collide across unrelated devices, and a wrong link is worse than none.
87
+ */
88
+ readonly usable: boolean;
89
+ }
90
+ /**
91
+ * Signals a browser may randomise between two reads in the same session.
92
+ *
93
+ * Kept as a guard even though canvas is no longer in the core, because the same
94
+ * per-read randomisation could appear on another signal — and because it
95
+ * catches Brave, which farbles per read rather than per session.
96
+ *
97
+ * It does **not** catch Safari, whose noise is seeded once per session: two
98
+ * renders agree, the check passes, and the value still differs the next time
99
+ * the browser is opened. That is why canvas was removed outright rather than
100
+ * guarded. An in-session test cannot detect a per-session seed.
101
+ */
102
+ declare const NOISE_TARGETS: readonly ["canvas.hash"];
103
+ export declare function stableCore(signals: Readonly<Record<string, SignalValue>>): StableCore;
104
+ /**
105
+ * The machine, separated from the browser running on it.
106
+ *
107
+ * Two tiers because they decay at different rates, and the difference between
108
+ * them is itself information. A browser update changes the engine tier and
109
+ * leaves the hardware tier alone; a new laptop changes both. Hashing all
110
+ * thirteen keys together means any single change mints a brand-new identity,
111
+ * so a returning customer who updated Chrome looks exactly like a stranger.
112
+ *
113
+ * The hardware tier is what a device-keyed limit should key on: it survives a
114
+ * cleared profile *and* a browser update, which is the combination a
115
+ * credential-stuffing run produces on purpose.
116
+ */
117
+ declare const HARDWARE_KEYS: readonly ["webgl.renderer", "webgl.vendor", "webgl.parametersHash", "webgl.fragmentHighFloat", "screen.colorDepth", "screen.devicePixelRatio", "nav.hardwareConcurrency", "nav.deviceMemory", "nav.maxTouchPoints"];
118
+ /** The build and its configuration: everything that moves when the browser does. */
119
+ declare const BROWSER_KEYS: readonly ["math.quirksHash", "err.nullAccessMessage", "nav.platform", "tz.timezone"];
120
+ export interface CoreTier {
121
+ readonly hash: string;
122
+ readonly present: number;
123
+ readonly total: number;
124
+ readonly usable: boolean;
125
+ }
126
+ export declare function hardwareCore(signals: Readonly<Record<string, SignalValue>>): CoreTier;
127
+ export declare function browserCore(signals: Readonly<Record<string, SignalValue>>): CoreTier;
128
+ export { STABLE_KEYS, NOISE_TARGETS, HARDWARE_KEYS, BROWSER_KEYS };
@@ -0,0 +1,187 @@
1
+ import { hashRecord } from './hash.js';
2
+ /**
3
+ * The stable core: what a device still looks like after it clears storage.
4
+ *
5
+ * The device id lives in cookies, localStorage and IndexedDB, so it is gone the
6
+ * moment someone opens a private window or clears site data — and with it goes
7
+ * every limit keyed on the device, including the credential-stuffing defence.
8
+ * That is not a bug in the storage layer; it is what private browsing *is*.
9
+ *
10
+ * The answer is a second, weaker identifier built only from signals that do not
11
+ * depend on storage at all: the GPU, the rendering stack, the CPU class, the
12
+ * screen. Those are the same in a private window as outside one.
13
+ *
14
+ * ## What is deliberately excluded
15
+ *
16
+ * Anything that changes on its own. A browser version, a window size, a
17
+ * connection type — include those and the core shifts under a returning user,
18
+ * which is the failure that makes a fingerprint useless: it stops recognising
19
+ * the people it is supposed to recognise.
20
+ *
21
+ * ## The privacy tension, stated plainly
22
+ *
23
+ * This links a private window to ordinary browsing on the same machine. Someone
24
+ * using a private window is often specifically trying to prevent that. There is
25
+ * no version of this that is both effective against storage clearing and
26
+ * respectful of the intent behind it — the two are the same act.
27
+ *
28
+ * So it is: hashed with the tenant salt before it is stored, never shared
29
+ * between tenants, suppressed entirely under `consent: 'deferred'`, and
30
+ * individually disableable. And it is weaker evidence than a real device id by
31
+ * design — enough to notice one device trying forty accounts, not enough to
32
+ * treat as identity.
33
+ */
34
+ /**
35
+ * Signals that survive a storage wipe and do not drift.
36
+ *
37
+ * Ordered for readability; the hash sorts them.
38
+ */
39
+ const STABLE_KEYS = [
40
+ // Rendering stack. These are strings the driver reports, not rendered output,
41
+ // which is what makes them survive fingerprinting protection.
42
+ 'webgl.renderer',
43
+ 'webgl.vendor',
44
+ 'webgl.parametersHash',
45
+ 'webgl.fragmentHighFloat',
46
+ // Engine and libm quirks: constant for a given browser build on given hardware.
47
+ 'math.quirksHash',
48
+ 'err.nullAccessMessage',
49
+ // Physical machine — the properties privacy modes leave alone.
50
+ 'screen.colorDepth',
51
+ 'screen.devicePixelRatio',
52
+ 'nav.hardwareConcurrency',
53
+ 'nav.deviceMemory',
54
+ 'nav.maxTouchPoints',
55
+ // Configuration a user rarely changes and never mid-session.
56
+ 'nav.platform',
57
+ 'tz.timezone',
58
+ ];
59
+ /**
60
+ * Keys left out, with the reason — so the next person to add one has to argue
61
+ * with something.
62
+ *
63
+ * nav.userAgent moves on every browser update
64
+ * features.hash moves on every browser update
65
+ * screen.innerWidth moves when the window is resized
66
+ * screen.chromeHeight needs focus, and moves with the toolbar
67
+ * nav.languages a user can change it, and some browsers reorder it
68
+ * canvas.stable a property of the browser's privacy settings, not the device
69
+ * nav.connectionType changes with the network, which is the point of it
70
+ *
71
+ * The next three were in the core until a measurement removed them. Safari 26,
72
+ * same machine, normal versus private browsing:
73
+ *
74
+ * canvas.hash 9cabb395 -> ec0c368d
75
+ * screen.width 1728 -> 1340
76
+ * screen.height 1117 -> 823
77
+ *
78
+ * Everything else was identical, so a core without these three would have
79
+ * matched — and with them it could not.
80
+ *
81
+ * canvas.hash Safari's fingerprinting protection seeds canvas noise **per
82
+ * session**. Two renders inside one session agree, so
83
+ * `canvas.stable` reports true and the noise check below does
84
+ * not fire. There is no in-session test that detects this;
85
+ * the only reliable answer is to keep rendered output out of
86
+ * a core that has to survive privacy modes.
87
+ *
88
+ * screen.width Safari private reports the *viewport* as the screen: 1340x823
89
+ * screen.height were exactly innerWidth and innerHeight. So these are not a
90
+ * different screen, they are a spoof — and one that moves
91
+ * again whenever the window is resized.
92
+ */
93
+ export const EXCLUDED_FROM_CORE = [
94
+ 'nav.userAgent',
95
+ 'features.hash',
96
+ 'screen.innerWidth',
97
+ 'screen.chromeHeight',
98
+ 'nav.languages',
99
+ 'canvas.stable',
100
+ 'nav.connectionType',
101
+ 'canvas.hash',
102
+ 'screen.width',
103
+ 'screen.height',
104
+ ];
105
+ const MINIMUM_KEYS = 8;
106
+ /**
107
+ * Signals a browser may randomise between two reads in the same session.
108
+ *
109
+ * Kept as a guard even though canvas is no longer in the core, because the same
110
+ * per-read randomisation could appear on another signal — and because it
111
+ * catches Brave, which farbles per read rather than per session.
112
+ *
113
+ * It does **not** catch Safari, whose noise is seeded once per session: two
114
+ * renders agree, the check passes, and the value still differs the next time
115
+ * the browser is opened. That is why canvas was removed outright rather than
116
+ * guarded. An in-session test cannot detect a per-session seed.
117
+ */
118
+ const NOISE_TARGETS = ['canvas.hash'];
119
+ export function stableCore(signals) {
120
+ const present = {};
121
+ // `canvas.stable` is the collector rendering twice and comparing. False means
122
+ // the browser is injecting noise, and we already know not to trust it.
123
+ const canvasRandomised = signals['canvas.stable'] === false;
124
+ for (const key of STABLE_KEYS) {
125
+ if (canvasRandomised && NOISE_TARGETS.includes(key))
126
+ continue;
127
+ const value = signals[key];
128
+ if (value !== undefined && value !== null && value !== '')
129
+ present[key] = value;
130
+ }
131
+ const count = Object.keys(present).length;
132
+ return {
133
+ hash: hashRecord(present),
134
+ present: count,
135
+ total: STABLE_KEYS.length,
136
+ parts: present,
137
+ noiseExcluded: canvasRandomised,
138
+ // A core built from three values is not an identifier, it is a coincidence
139
+ // waiting to happen. Saying so is cheaper than explaining a wrong link.
140
+ usable: count >= MINIMUM_KEYS,
141
+ };
142
+ }
143
+ /**
144
+ * The machine, separated from the browser running on it.
145
+ *
146
+ * Two tiers because they decay at different rates, and the difference between
147
+ * them is itself information. A browser update changes the engine tier and
148
+ * leaves the hardware tier alone; a new laptop changes both. Hashing all
149
+ * thirteen keys together means any single change mints a brand-new identity,
150
+ * so a returning customer who updated Chrome looks exactly like a stranger.
151
+ *
152
+ * The hardware tier is what a device-keyed limit should key on: it survives a
153
+ * cleared profile *and* a browser update, which is the combination a
154
+ * credential-stuffing run produces on purpose.
155
+ */
156
+ const HARDWARE_KEYS = [
157
+ 'webgl.renderer',
158
+ 'webgl.vendor',
159
+ 'webgl.parametersHash',
160
+ 'webgl.fragmentHighFloat',
161
+ 'screen.colorDepth',
162
+ 'screen.devicePixelRatio',
163
+ 'nav.hardwareConcurrency',
164
+ 'nav.deviceMemory',
165
+ 'nav.maxTouchPoints',
166
+ ];
167
+ /** The build and its configuration: everything that moves when the browser does. */
168
+ const BROWSER_KEYS = ['math.quirksHash', 'err.nullAccessMessage', 'nav.platform', 'tz.timezone'];
169
+ /** Hardware needs fewer keys to be worth trusting, because each carries more. */
170
+ const MINIMUM_HARDWARE_KEYS = 5;
171
+ function tier(signals, keys, minimum) {
172
+ const present = {};
173
+ for (const key of keys) {
174
+ const value = signals[key];
175
+ if (value !== undefined && value !== null && value !== '')
176
+ present[key] = value;
177
+ }
178
+ const count = Object.keys(present).length;
179
+ return { hash: hashRecord(present), present: count, total: keys.length, usable: count >= minimum };
180
+ }
181
+ export function hardwareCore(signals) {
182
+ return tier(signals, HARDWARE_KEYS, MINIMUM_HARDWARE_KEYS);
183
+ }
184
+ export function browserCore(signals) {
185
+ return tier(signals, BROWSER_KEYS, 2);
186
+ }
187
+ export { STABLE_KEYS, NOISE_TARGETS, HARDWARE_KEYS, BROWSER_KEYS };
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Collectors run in three budgeted waves so a protected action is never
3
+ * delayed. A wave that exceeds its budget is abandoned and reported, never
4
+ * awaited — a slow fingerprint is worse than a missing one.
5
+ *
6
+ * See docs/signals.md section 1.
7
+ */
8
+ export type Wave = 'instant' | 'warm' | 'deep';
9
+ /**
10
+ * How long each wave may take before the rest of it is skipped.
11
+ *
12
+ * Generous on purpose. A budget is a ceiling, not a cost: a fast device
13
+ * finishes the instant wave in a few milliseconds and never comes near it. The
14
+ * ceiling exists for the slow device, and the old one (instant 5 ms, warm
15
+ * 60 ms) was set against desktop timings — a mid-range phone runs these several
16
+ * times slower and lost half its signals, bot detection among them. The total a
17
+ * protected action waits is still capped by the SDK's `timeoutMs`.
18
+ */
19
+ export declare const WAVE_BUDGET_MS: Record<Wave, number>;
20
+ /** Everything a collector is allowed to touch, injected rather than global. */
21
+ export interface CollectorEnv {
22
+ readonly window: Window & typeof globalThis;
23
+ readonly navigator: Navigator;
24
+ readonly document: Document;
25
+ readonly screen: Screen;
26
+ readonly performance: Performance;
27
+ }
28
+ export type SignalValue = string | number | boolean | null;
29
+ export interface Collector {
30
+ readonly id: string;
31
+ readonly wave: Wave;
32
+ /**
33
+ * The tenant may switch this off without it counting as missing evidence.
34
+ *
35
+ * Disabling a required collector lowers confidence, because the signal is
36
+ * genuinely absent. Disabling an optional one does not: it is a choice the
37
+ * product offered, and charging confidence for taking it means penalising a
38
+ * tenant for using their own privacy control on every request thereafter.
39
+ *
40
+ * Says nothing about a collector that ran and found nothing. That is a real
41
+ * absence and still counts.
42
+ */
43
+ readonly optional?: boolean;
44
+ collect(env: CollectorEnv): Promise<Record<string, SignalValue>> | Record<string, SignalValue>;
45
+ }
46
+ export interface CollectionResult {
47
+ readonly signals: Record<string, SignalValue>;
48
+ readonly durationMs: number;
49
+ /** Collectors that threw, timed out, or were disabled. */
50
+ readonly skipped: readonly string[];
51
+ readonly errors: readonly string[];
52
+ /** 0..1 — how much of the expected signal set was actually obtained. */
53
+ readonly confidence: number;
54
+ }
package/dist/types.js ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * How long each wave may take before the rest of it is skipped.
3
+ *
4
+ * Generous on purpose. A budget is a ceiling, not a cost: a fast device
5
+ * finishes the instant wave in a few milliseconds and never comes near it. The
6
+ * ceiling exists for the slow device, and the old one (instant 5 ms, warm
7
+ * 60 ms) was set against desktop timings — a mid-range phone runs these several
8
+ * times slower and lost half its signals, bot detection among them. The total a
9
+ * protected action waits is still capped by the SDK's `timeoutMs`.
10
+ */
11
+ export const WAVE_BUDGET_MS = {
12
+ instant: 60,
13
+ warm: 200,
14
+ deep: 400,
15
+ };
package/package.json CHANGED
@@ -1,6 +1,34 @@
1
1
  {
2
2
  "name": "@magield/collectors",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.7.1",
4
+ "description": "Browser signal collectors used by the Magic Shield web SDK",
5
+ "license": "SEE LICENSE IN LICENSE",
6
+ "files": [
7
+ "dist",
8
+ "!**/*.map",
9
+ "LICENSE"
10
+ ],
11
+ "publishConfig": {
12
+ "access": "public",
13
+ "registry": "https://registry.npmjs.org/"
14
+ },
15
+ "type": "module",
16
+ "exports": {
17
+ ".": "./dist/index.js"
18
+ },
19
+ "main": "./dist/index.js",
20
+ "types": "./dist/index.d.ts",
21
+ "sideEffects": false,
22
+ "devDependencies": {
23
+ "@types/node": "^22.10.2",
24
+ "typescript": "^5.7.2",
25
+ "vitest": "^2.1.8"
26
+ },
27
+ "scripts": {
28
+ "build": "tsc -p tsconfig.json",
29
+ "typecheck": "tsc -p tsconfig.json --noEmit",
30
+ "typecheck:tests": "tsc -p tsconfig.test.json",
31
+ "test": "vitest run",
32
+ "clean": "rm -rf dist .turbo"
33
+ }
6
34
  }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.