@magield/node 0.0.0-stage → 0.6.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.
package/LICENSE ADDED
@@ -0,0 +1,27 @@
1
+ Copyright (c) 2026 MagicShield. All rights reserved.
2
+
3
+ MAGIC SHIELD SDK LICENCE
4
+
5
+ This package is published to a public registry so that licensed customers can
6
+ install it with ordinary tooling. Being able to download it does not grant any
7
+ right to use it.
8
+
9
+ You may install, use and redistribute this package, unmodified and only as part
10
+ of your own application, solely to integrate that application with a Magic
11
+ Shield deployment that you are licensed to use under a written agreement with
12
+ MagicShield ("your Agreement"), and only for the term of your Agreement.
13
+
14
+ Except as your Agreement expressly allows, you may not:
15
+
16
+ - use this package for any other purpose, including to build, benchmark or
17
+ assist a competing product;
18
+ - modify it, or reverse engineer, decompile, deobfuscate or disassemble it,
19
+ except to the extent this restriction is unenforceable under applicable law;
20
+ - remove or alter this notice or any copyright or attribution in it.
21
+
22
+ THE PACKAGE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXCEPT AS YOUR
23
+ AGREEMENT PROVIDES. IN NO EVENT SHALL MAGICSHIELD BE LIABLE FOR ANY CLAIM,
24
+ DAMAGES OR OTHER LIABILITY ARISING FROM THE PACKAGE OR ITS USE, EXCEPT AS YOUR
25
+ AGREEMENT PROVIDES.
26
+
27
+ Your Agreement governs if it conflicts with this notice.
@@ -0,0 +1,41 @@
1
+ /**
2
+ * A circuit breaker over a rolling window.
3
+ *
4
+ * `docs/api.md` §5 promises one, and the reason is latency rather than load.
5
+ * When the gateway is unreachable, every call still pays its full deadline
6
+ * before failing open — forty milliseconds added to every sign-in, for as long
7
+ * as the outage lasts. The breaker is what turns that into forty milliseconds
8
+ * once and then nothing.
9
+ *
10
+ * Counts rather than a rate: a window with three calls in it has no meaningful
11
+ * error rate, and tripping on one failure out of two would take a deployment
12
+ * offline over a single timeout. `MIN_CALLS` is the floor below which the
13
+ * breaker declines to have an opinion.
14
+ *
15
+ * Half-open is a single probe. Letting the whole load through the moment the
16
+ * reset elapses is how a recovering service gets knocked over again by the
17
+ * traffic that was waiting for it.
18
+ */
19
+ export interface BreakerOptions {
20
+ /** Share of calls that must fail before the circuit opens. */
21
+ readonly errorThreshold?: number;
22
+ readonly windowMs?: number;
23
+ readonly resetMs?: number;
24
+ }
25
+ type State = 'closed' | 'open' | 'half_open';
26
+ export declare class Breaker {
27
+ #private;
28
+ constructor(options?: BreakerOptions, now?: () => number);
29
+ get state(): State;
30
+ /**
31
+ * Whether to attempt the call.
32
+ *
33
+ * Moves the breaker to half-open as a side effect once the reset has elapsed,
34
+ * so exactly one caller gets through to find out whether the service is back.
35
+ */
36
+ allows(): boolean;
37
+ succeeded(): void;
38
+ failed(): void;
39
+ }
40
+ export {};
41
+ //# sourceMappingURL=breaker.d.ts.map
@@ -0,0 +1,91 @@
1
+ /**
2
+ * A circuit breaker over a rolling window.
3
+ *
4
+ * `docs/api.md` §5 promises one, and the reason is latency rather than load.
5
+ * When the gateway is unreachable, every call still pays its full deadline
6
+ * before failing open — forty milliseconds added to every sign-in, for as long
7
+ * as the outage lasts. The breaker is what turns that into forty milliseconds
8
+ * once and then nothing.
9
+ *
10
+ * Counts rather than a rate: a window with three calls in it has no meaningful
11
+ * error rate, and tripping on one failure out of two would take a deployment
12
+ * offline over a single timeout. `MIN_CALLS` is the floor below which the
13
+ * breaker declines to have an opinion.
14
+ *
15
+ * Half-open is a single probe. Letting the whole load through the moment the
16
+ * reset elapses is how a recovering service gets knocked over again by the
17
+ * traffic that was waiting for it.
18
+ */
19
+ const DEFAULTS = { errorThreshold: 0.5, windowMs: 10_000, resetMs: 30_000 };
20
+ /** Below this, a failure rate is noise. */
21
+ const MIN_CALLS = 5;
22
+ export class Breaker {
23
+ #threshold;
24
+ #windowMs;
25
+ #resetMs;
26
+ #now;
27
+ #state = 'closed';
28
+ #openedAt = 0;
29
+ /** Timestamps, newest last. Trimmed on read, so an idle breaker costs nothing. */
30
+ #failures = [];
31
+ #successes = [];
32
+ constructor(options = {}, now = Date.now) {
33
+ this.#threshold = options.errorThreshold ?? DEFAULTS.errorThreshold;
34
+ this.#windowMs = options.windowMs ?? DEFAULTS.windowMs;
35
+ this.#resetMs = options.resetMs ?? DEFAULTS.resetMs;
36
+ this.#now = now;
37
+ }
38
+ get state() {
39
+ return this.#state;
40
+ }
41
+ /**
42
+ * Whether to attempt the call.
43
+ *
44
+ * Moves the breaker to half-open as a side effect once the reset has elapsed,
45
+ * so exactly one caller gets through to find out whether the service is back.
46
+ */
47
+ allows() {
48
+ if (this.#state === 'closed')
49
+ return true;
50
+ if (this.#state === 'open' && this.#now() - this.#openedAt >= this.#resetMs) {
51
+ this.#state = 'half_open';
52
+ return true;
53
+ }
54
+ return false;
55
+ }
56
+ succeeded() {
57
+ if (this.#state === 'half_open') {
58
+ // The probe came back. Start clean rather than carrying the failures that
59
+ // opened the circuit, or the next single error reopens it immediately.
60
+ this.#state = 'closed';
61
+ this.#failures = [];
62
+ this.#successes = [];
63
+ return;
64
+ }
65
+ this.#successes.push(this.#now());
66
+ this.#trim();
67
+ }
68
+ failed() {
69
+ if (this.#state === 'half_open') {
70
+ this.#open();
71
+ return;
72
+ }
73
+ this.#failures.push(this.#now());
74
+ this.#trim();
75
+ const total = this.#failures.length + this.#successes.length;
76
+ if (total >= MIN_CALLS && this.#failures.length / total >= this.#threshold)
77
+ this.#open();
78
+ }
79
+ #open() {
80
+ this.#state = 'open';
81
+ this.#openedAt = this.#now();
82
+ this.#failures = [];
83
+ this.#successes = [];
84
+ }
85
+ #trim() {
86
+ const cutoff = this.#now() - this.#windowMs;
87
+ this.#failures = this.#failures.filter((at) => at > cutoff);
88
+ this.#successes = this.#successes.filter((at) => at > cutoff);
89
+ }
90
+ }
91
+ //# sourceMappingURL=breaker.js.map
@@ -0,0 +1,100 @@
1
+ import { Decision } from '@magield/proto';
2
+ import { type BreakerOptions } from './breaker.js';
3
+ import type { DecisionName, EvaluateInput, FeedbackInput, LimitCheckInput, LimitVerdict, TrustDeviceInput, TrustedDevice, Verdict } from './types.js';
4
+ /**
5
+ * The backend client.
6
+ *
7
+ * Magic Shield is never called from a browser. This runs on the integrator's
8
+ * server, holds the API key, and is the only thing that talks to the gateway.
9
+ *
10
+ * It exists because five places in this repository already hand-rolled it and
11
+ * each got a different subset right — one set keepalive, none used the
12
+ * documented deadline, four retyped the metadata key, and not one implemented
13
+ * retry, a breaker or connection reuse. `docs/api.md` §5 says "the integrator
14
+ * should never have to think about any of it", and that only becomes true when
15
+ * it lives somewhere.
16
+ *
17
+ * Nothing here throws into a sign-in. A transport failure produces a verdict
18
+ * that says so, and the policy for what to do about it is configuration rather
19
+ * than an exception somebody has to remember to catch.
20
+ */
21
+ export declare const API_KEY_METADATA = "x-mshield-api-key";
22
+ export interface ShieldDefaults {
23
+ /**
24
+ * Per-call deadline. `docs/api.md` §5 says 40 ms, and means it — the pipeline
25
+ * targets a p99 of 12 ms in-process, so a deadline an order of magnitude
26
+ * above that is measuring the network, not the engine.
27
+ */
28
+ readonly deadlineMs?: number;
29
+ /**
30
+ * What an unreachable gateway means.
31
+ *
32
+ * `true` by default, and this is the most consequential line in the file: it
33
+ * decides what happens to somebody else's sign-in page when this service is
34
+ * down. Magic Shield must never be the reason a site is unavailable.
35
+ */
36
+ readonly failOpen?: boolean;
37
+ /** The decision a fail-open verdict carries. Not `allow`, so it is visible. */
38
+ readonly failOpenDecision?: DecisionName;
39
+ /** Attempts per call, including the first. */
40
+ readonly maxAttempts?: number;
41
+ }
42
+ export interface ShieldConfig {
43
+ /** `host:port` of the gateway. Internal only — it must not be public. */
44
+ readonly target: string;
45
+ readonly apiKey: string;
46
+ /** The application these calls are for. Checked against the key's binding. */
47
+ readonly appId: string;
48
+ /**
49
+ * Server TLS. `false` is plaintext and is for local development only.
50
+ *
51
+ * A PEM string or a path, matching what the gateway itself accepts.
52
+ */
53
+ readonly tls?: {
54
+ readonly ca?: string | Buffer;
55
+ } | false;
56
+ readonly defaults?: ShieldDefaults;
57
+ readonly breaker?: BreakerOptions;
58
+ /**
59
+ * Where failures go.
60
+ *
61
+ * A callback rather than a thrown error, the same choice the web SDK makes:
62
+ * there is no path out of this API that throws into an integrator's request
63
+ * handler, so the errors have to arrive somewhere else.
64
+ */
65
+ readonly onError?: (error: Error) => void;
66
+ }
67
+ export declare class ShieldClient {
68
+ #private;
69
+ constructor(config: ShieldConfig);
70
+ /**
71
+ * Scores a request.
72
+ *
73
+ * Never throws. When the gateway cannot answer, the verdict says `degraded`
74
+ * and carries the configured fail-open decision, so the calling code is the
75
+ * same on the good path and the bad one.
76
+ */
77
+ evaluate(input: EvaluateInput): Promise<Verdict>;
78
+ /**
79
+ * Reports what happened after a decision.
80
+ *
81
+ * Prefer `verdict.reportOutcome`, which cannot lose the `evaluationId`. This
82
+ * exists for the case where the verdict was not kept — a worker reconciling
83
+ * yesterday's chargebacks, say.
84
+ */
85
+ feedback(input: FeedbackInput): Promise<void>;
86
+ /** Enrols a device so it can earn trust. */
87
+ trustDevice(input: TrustDeviceInput): Promise<TrustedDevice>;
88
+ /**
89
+ * Asks the limiter directly, without scoring.
90
+ *
91
+ * For the endpoints that have a budget but no risk question — an export, a
92
+ * password-reset email, anything where the answer is "how often" rather than
93
+ * "who".
94
+ */
95
+ limitCheck(input: LimitCheckInput): Promise<LimitVerdict>;
96
+ close(): void;
97
+ }
98
+ export declare function createClient(config: ShieldConfig): ShieldClient;
99
+ export { Decision };
100
+ //# sourceMappingURL=client.d.ts.map
package/dist/client.js ADDED
@@ -0,0 +1,407 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { credentials, Metadata, status } from '@grpc/grpc-js';
3
+ import { CheckMode, CheckRequest, Decision, DevicesClient, EvaluateRequest, EvaluationClient, FeedbackRequest, LimitsClient, TrustRequest, TrustState, } from '@magield/proto';
4
+ import { Breaker } from './breaker.js';
5
+ import { decisionName, modeName, outcomeValue } from './decisions.js';
6
+ /**
7
+ * The backend client.
8
+ *
9
+ * Magic Shield is never called from a browser. This runs on the integrator's
10
+ * server, holds the API key, and is the only thing that talks to the gateway.
11
+ *
12
+ * It exists because five places in this repository already hand-rolled it and
13
+ * each got a different subset right — one set keepalive, none used the
14
+ * documented deadline, four retyped the metadata key, and not one implemented
15
+ * retry, a breaker or connection reuse. `docs/api.md` §5 says "the integrator
16
+ * should never have to think about any of it", and that only becomes true when
17
+ * it lives somewhere.
18
+ *
19
+ * Nothing here throws into a sign-in. A transport failure produces a verdict
20
+ * that says so, and the policy for what to do about it is configuration rather
21
+ * than an exception somebody has to remember to catch.
22
+ */
23
+ export const API_KEY_METADATA = 'x-mshield-api-key';
24
+ const DEFAULT_DEADLINE_MS = 40;
25
+ const DEFAULT_MAX_ATTEMPTS = 2;
26
+ /** Retried. Both mean the call did not produce a decision. */
27
+ const RETRYABLE = new Set([status.UNAVAILABLE, status.DEADLINE_EXCEEDED]);
28
+ export class ShieldClient {
29
+ #evaluation;
30
+ #devices;
31
+ #limits;
32
+ #metadata;
33
+ #breaker;
34
+ #config;
35
+ constructor(config) {
36
+ this.#config = config;
37
+ // One long-lived Metadata, built once. Four of the five hand-rolled clients
38
+ // in this repository build a fresh one per call and retype the key.
39
+ this.#metadata = new Metadata();
40
+ this.#metadata.set(API_KEY_METADATA, config.apiKey);
41
+ const channel = this.#credentials();
42
+ const options = {
43
+ // Proven out by the edge collector, which is the one existing client that
44
+ // holds a connection open long enough to need them.
45
+ 'grpc.keepalive_time_ms': 30_000,
46
+ 'grpc.keepalive_timeout_ms': 10_000,
47
+ 'grpc.keepalive_permit_without_calls': 1,
48
+ 'grpc.max_receive_message_length': 4 * 1024 * 1024,
49
+ };
50
+ // Three clients, and grpc-js pools the underlying subchannel by target and
51
+ // credentials — so this is one connection, not three.
52
+ this.#evaluation = new EvaluationClient(config.target, channel, options);
53
+ this.#devices = new DevicesClient(config.target, channel, options);
54
+ this.#limits = new LimitsClient(config.target, channel, options);
55
+ this.#breaker = new Breaker(config.breaker ?? {});
56
+ }
57
+ /**
58
+ * Scores a request.
59
+ *
60
+ * Never throws. When the gateway cannot answer, the verdict says `degraded`
61
+ * and carries the configured fail-open decision, so the calling code is the
62
+ * same on the good path and the bad one.
63
+ */
64
+ async evaluate(input) {
65
+ const requestId = input.requestId ?? randomUUID();
66
+ try {
67
+ const response = await this.#call((metadata, options, callback) => this.#evaluation.evaluate(this.#evaluateRequest(input, requestId), metadata, options, callback), input.deadlineMs);
68
+ return this.#verdict(response);
69
+ }
70
+ catch (err) {
71
+ return this.#failOpen(err);
72
+ }
73
+ }
74
+ /**
75
+ * Reports what happened after a decision.
76
+ *
77
+ * Prefer `verdict.reportOutcome`, which cannot lose the `evaluationId`. This
78
+ * exists for the case where the verdict was not kept — a worker reconciling
79
+ * yesterday's chargebacks, say.
80
+ */
81
+ async feedback(input) {
82
+ // One of the two has to name a decision. Sending neither would be a call
83
+ // that returns 200 and can never be joined to anything — the exact silent
84
+ // failure this path is built to prevent.
85
+ if ((input.evaluationId ?? '') === '' && (input.requestId ?? '') === '') {
86
+ this.#config.onError?.(new Error('feedback needs evaluationId or requestId: an outcome that names no decision is lost'));
87
+ return;
88
+ }
89
+ try {
90
+ await this.#call((metadata, options, callback) => this.#evaluation.feedback(FeedbackRequest.fromPartial({
91
+ evaluationId: input.evaluationId ?? '',
92
+ requestId: input.requestId ?? '',
93
+ outcome: outcomeValue(input.outcome),
94
+ note: input.note ?? '',
95
+ metadata: { ...(input.metadata ?? {}) },
96
+ occurredAtMs: input.occurredAtMs ?? Date.now(),
97
+ action: input.action ?? '',
98
+ ...(input.keys === undefined ? {} : { keys: identifiers(input.keys) }),
99
+ }), metadata, options, callback), undefined);
100
+ }
101
+ catch (err) {
102
+ // Swallowed on purpose. A sign-in that already worked must not fail
103
+ // because a bookkeeping call did.
104
+ this.#config.onError?.(err);
105
+ }
106
+ }
107
+ /** Enrols a device so it can earn trust. */
108
+ async trustDevice(input) {
109
+ const device = await this.#call((metadata, options, callback) => this.#devices.trust(TrustRequest.fromPartial({
110
+ token: Buffer.from(input.deviceId, 'utf8'),
111
+ publicKey: input.publicKey,
112
+ authContext: input.authContext ?? '',
113
+ userId: '',
114
+ label: '',
115
+ ttlDays: 0,
116
+ stableCore: '',
117
+ }), metadata, options, callback), undefined);
118
+ return {
119
+ deviceId: device.deviceId,
120
+ state: (TrustState[device.state] ?? 'TRUST_STATE_UNSPECIFIED').replace(/^TRUST_STATE_/, '').toLowerCase(),
121
+ trustedAtMs: device.firstSeenMs,
122
+ lastSeenMs: device.lastSeenMs,
123
+ };
124
+ }
125
+ /**
126
+ * Asks the limiter directly, without scoring.
127
+ *
128
+ * For the endpoints that have a budget but no risk question — an export, a
129
+ * password-reset email, anything where the answer is "how often" rather than
130
+ * "who".
131
+ */
132
+ async limitCheck(input) {
133
+ try {
134
+ const response = await this.#call((metadata, options, callback) => this.#limits.check(CheckRequest.fromPartial({
135
+ appId: input.appId ?? this.#config.appId,
136
+ action: input.action,
137
+ mode: input.peek === true ? CheckMode.CHECK_MODE_PEEK : CheckMode.CHECK_MODE_CONSUME,
138
+ limitIds: [],
139
+ cost: 1,
140
+ ...(input.keys === undefined ? {} : { keys: identifiers(input.keys) }),
141
+ ...(input.ip === undefined ? {} : { network: networkContext({ ip: input.ip }) }),
142
+ }), metadata, options, callback), undefined);
143
+ return {
144
+ allowed: response.allowed,
145
+ decision: decisionName(response.decision),
146
+ retryAfterMs: Number(response.retryAfterMs),
147
+ limits: response.limits.map(limitState),
148
+ };
149
+ }
150
+ catch (err) {
151
+ this.#config.onError?.(err);
152
+ // Fail open, for the reason the limiter itself does: counters that
153
+ // rebuild are not worth refusing traffic over.
154
+ return { allowed: true, decision: 'allow_flagged', retryAfterMs: 0, limits: [] };
155
+ }
156
+ }
157
+ close() {
158
+ this.#evaluation.close();
159
+ this.#devices.close();
160
+ this.#limits.close();
161
+ }
162
+ /**
163
+ * One call, with the breaker, the deadline and the retry around it.
164
+ *
165
+ * The retry is only safe because the gateway deduplicates on `request_id` for
166
+ * sixty seconds. Before that window existed, a retry re-ran the pipeline and
167
+ * spent rate-limit budget a second time — so the documented behaviour was the
168
+ * unsafe one, and this client could not have honoured it.
169
+ */
170
+ async #call(invoke, deadlineMs) {
171
+ if (!this.#breaker.allows()) {
172
+ throw Object.assign(new Error('circuit open'), { code: status.UNAVAILABLE });
173
+ }
174
+ const budget = deadlineMs ?? this.#config.defaults?.deadlineMs ?? DEFAULT_DEADLINE_MS;
175
+ const attempts = this.#config.defaults?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
176
+ let last;
177
+ for (let attempt = 0; attempt < attempts; attempt++) {
178
+ if (attempt > 0)
179
+ await sleep(backoffMs(attempt));
180
+ try {
181
+ const value = await new Promise((resolve, reject) => {
182
+ invoke(this.#metadata, { deadline: Date.now() + budget }, (err, response) => {
183
+ if (err)
184
+ reject(err);
185
+ else
186
+ resolve(response);
187
+ });
188
+ });
189
+ this.#breaker.succeeded();
190
+ return value;
191
+ }
192
+ catch (err) {
193
+ last = err;
194
+ this.#breaker.failed();
195
+ if (!RETRYABLE.has(last.code ?? status.UNKNOWN))
196
+ break;
197
+ }
198
+ }
199
+ throw last ?? new Error('no attempt was made');
200
+ }
201
+ #evaluateRequest(input, requestId) {
202
+ // Built here so `Exact<>` never reaches an integrator. The generated
203
+ // `fromPartial` only infers from an object literal at the call site, which
204
+ // is why every hand-rolled client in this repository writes one inline.
205
+ return EvaluateRequest.fromPartial({
206
+ requestId,
207
+ appId: input.appId ?? this.#config.appId,
208
+ action: input.action,
209
+ token: Buffer.from(input.token ?? '', 'utf8'),
210
+ context: { ...(input.context ?? {}) },
211
+ ...(input.keys === undefined ? {} : { keys: identifiers(input.keys) }),
212
+ ...(input.network === undefined && input.ip === undefined
213
+ ? {}
214
+ : { network: networkContext({ ip: input.ip, ...input.network }) }),
215
+ ...(input.solution === undefined
216
+ ? {}
217
+ : {
218
+ solution: {
219
+ challengeId: '',
220
+ blob: Buffer.from(input.solution.blob, 'utf8'),
221
+ // The proto calls it `solution`; the web SDK calls it `nonce`.
222
+ // Translating here is the whole reason this method exists.
223
+ solution: Buffer.from(input.solution.nonce, 'utf8'),
224
+ solveMs: input.solution.solveMs,
225
+ },
226
+ }),
227
+ ...(input.verboseReasons === true
228
+ ? { options: { verboseReasons: true, includeFingerprints: false, peek: false, modeOverride: 0 } }
229
+ : {}),
230
+ });
231
+ }
232
+ #verdict(response) {
233
+ const decision = decisionName(response.decision);
234
+ const client = this;
235
+ const base = {
236
+ evaluationId: response.evaluationId,
237
+ score: response.score,
238
+ confidence: response.confidence,
239
+ reasons: response.reasons.map((reason) => ({
240
+ code: reason.code,
241
+ dimension: reason.dimension,
242
+ weight: reason.weight,
243
+ confidence: reason.confidence,
244
+ message: reason.message,
245
+ ...(Object.keys(reason.detail).length === 0 ? {} : { detail: reason.detail }),
246
+ })),
247
+ subscores: response.subscores,
248
+ limits: response.limits.map(limitState),
249
+ degraded: response.degraded,
250
+ deviceId: response.deviceId,
251
+ modeApplied: modeName(response.modeApplied),
252
+ ...(response.wouldHave === undefined
253
+ ? {}
254
+ : { wouldHave: { decision: decisionName(response.wouldHave.decision), score: response.wouldHave.score } }),
255
+ async reportOutcome(outcome, extra = {}) {
256
+ await client.feedback({ evaluationId: response.evaluationId, outcome, ...extra });
257
+ },
258
+ };
259
+ if (decision === 'challenge' && response.challenge !== undefined) {
260
+ return {
261
+ ...base,
262
+ decision,
263
+ challenge: {
264
+ blob: Buffer.from(response.challenge.blob).toString('utf8'),
265
+ difficulty: response.challenge.difficulty,
266
+ // Zero means one, which is what an older gateway issues.
267
+ subtasks: response.challenge.subtasks === 0 ? 1 : response.challenge.subtasks,
268
+ algorithm: response.challenge.algorithm === '' ? 'sha256' : response.challenge.algorithm,
269
+ expectedMs: response.challenge.expectedMs,
270
+ ttlMs: response.challenge.ttlMs,
271
+ },
272
+ };
273
+ }
274
+ if (decision === 'throttle')
275
+ return { ...base, decision, retryAfterMs: response.retryAfterMs };
276
+ // A challenge decision with no challenge cannot reach here — the gateway
277
+ // downgrades to `allow_flagged` when it cannot issue one — but the cast
278
+ // would be a lie if it ever did, so it is read as the safe neighbour.
279
+ return { ...base, decision: decision === 'challenge' ? 'allow_flagged' : decision };
280
+ }
281
+ /**
282
+ * The verdict for a call that never produced one.
283
+ *
284
+ * `evaluationId` is empty, so `reportOutcome` is a no-op: there is no
285
+ * decision to join an outcome to, and recording one against nothing would
286
+ * corrupt the very measurement outcomes exist to feed.
287
+ */
288
+ #failOpen(err) {
289
+ this.#config.onError?.(err);
290
+ if (this.#config.defaults?.failOpen === false)
291
+ throw err;
292
+ /*
293
+ * Failing open on a misconfiguration still fails open — and says so.
294
+ *
295
+ * Every error took this path and every one of them came back as
296
+ * `MSHIELD_UNAVAILABLE`, "the gateway could not be reached". For a down
297
+ * gateway that is true. For a wrong API key, a key bound to another
298
+ * `appId`, or a malformed field it is a lie, and an expensive one: the
299
+ * integration appears to work, allows everything, scores nothing, and
300
+ * keeps doing so until somebody notices the console is empty. A
301
+ * misconfigured deployment that looks healthy is the worst outcome this
302
+ * SDK can produce.
303
+ *
304
+ * Still fail open, because the alternative is taking somebody's sign-in
305
+ * page down over a configuration mistake. But name it correctly, so
306
+ * `onError`, the reason code and any alert built on either tell the truth.
307
+ */
308
+ const misconfigured = err.code === status.UNAUTHENTICATED ||
309
+ err.code === status.PERMISSION_DENIED ||
310
+ err.code === status.INVALID_ARGUMENT;
311
+ return {
312
+ evaluationId: '',
313
+ decision: this.#config.defaults?.failOpenDecision ?? 'allow_flagged',
314
+ score: 0,
315
+ confidence: 0,
316
+ reasons: [
317
+ {
318
+ code: misconfigured ? 'MSHIELD_MISCONFIGURED' : 'MSHIELD_UNAVAILABLE',
319
+ dimension: 'none',
320
+ weight: 0,
321
+ confidence: 0,
322
+ message: misconfigured
323
+ ? `this call was refused and will keep being refused until it is fixed: ${err.message}`
324
+ : `the gateway could not be reached: ${err.message}`,
325
+ },
326
+ ],
327
+ subscores: {},
328
+ limits: [],
329
+ degraded: true,
330
+ deviceId: '',
331
+ modeApplied: 'unspecified',
332
+ reportOutcome: async () => undefined,
333
+ };
334
+ }
335
+ #credentials() {
336
+ const tls = this.#config.tls;
337
+ if (tls === false || tls === undefined)
338
+ return credentials.createInsecure();
339
+ return tls.ca === undefined
340
+ ? credentials.createSsl()
341
+ : credentials.createSsl(typeof tls.ca === 'string' ? Buffer.from(tls.ca, 'utf8') : tls.ca);
342
+ }
343
+ }
344
+ export function createClient(config) {
345
+ return new ShieldClient(config);
346
+ }
347
+ /** Full jitter. Two clients retrying in step is how a recovering service stays down. */
348
+ function backoffMs(attempt) {
349
+ return Math.random() * Math.min(100, 2 ** attempt * 20);
350
+ }
351
+ function sleep(ms) {
352
+ return new Promise((resolve) => {
353
+ const timer = setTimeout(resolve, ms);
354
+ timer.unref?.();
355
+ });
356
+ }
357
+ function identifiers(keys) {
358
+ return {
359
+ username: keys.username ?? '',
360
+ userId: keys.userId ?? '',
361
+ email: keys.email ?? '',
362
+ phone: keys.phone ?? '',
363
+ sessionId: keys.sessionId ?? '',
364
+ apiKeyId: keys.apiKeyId ?? '',
365
+ passwordSha1Prefix: keys.passwordSha1Prefix ?? '',
366
+ custom: { ...(keys.custom ?? {}) },
367
+ };
368
+ }
369
+ function networkContext(network) {
370
+ return {
371
+ ip: network.ip ?? '',
372
+ forwardedFor: network.forwardedFor ?? '',
373
+ ja4: network.ja4 ?? '',
374
+ ja4s: '',
375
+ ja3: network.ja3 ?? '',
376
+ http2Fp: '',
377
+ tcpFp: '',
378
+ headerOrder: [...(network.headerOrder ?? [])],
379
+ headers: {
380
+ ...(network.headers ?? {}),
381
+ ...(network.userAgent === undefined ? {} : { 'user-agent': network.userAgent }),
382
+ },
383
+ tlsVersion: '',
384
+ tlsCipher: '',
385
+ tlsResumed: false,
386
+ httpVersion: '',
387
+ clientTcpRttMs: 0,
388
+ source: 0,
389
+ cfBotScore: 0,
390
+ cfVerifiedBot: false,
391
+ };
392
+ }
393
+ function limitState(state) {
394
+ // int64 fields arrive as plain numbers here, but every hand-rolled client in
395
+ // this repository coerces them anyway because the generated type says so.
396
+ return {
397
+ id: state.id,
398
+ limit: Number(state.limit),
399
+ remaining: Number(state.remaining),
400
+ resetAtMs: Number(state.resetAtMs),
401
+ exceeded: state.exceeded,
402
+ shadow: state.shadow,
403
+ keyPreview: state.keyPreview,
404
+ };
405
+ }
406
+ export { Decision };
407
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1,17 @@
1
+ import { Decision, Mode, Outcome } from '@magield/proto';
2
+ import type { DecisionName, OutcomeName } from './types.js';
3
+ /**
4
+ * Reads a decision off the wire.
5
+ *
6
+ * `DECISION_UNSPECIFIED` and anything this build has never heard of both become
7
+ * `allow_flagged`: let it through, and record that something was there. Mapping
8
+ * an unknown decision to `allow` would discard a signal the gateway meant to
9
+ * send; mapping it to `deny` would block real people because a server was
10
+ * upgraded first. `allow_flagged` is the reading that is wrong in the smallest
11
+ * way in either direction.
12
+ */
13
+ export declare function decisionName(decision: Decision): DecisionName;
14
+ export declare function outcomeValue(outcome: OutcomeName): Outcome;
15
+ /** For display and logging only. Never compared against. */
16
+ export declare function modeName(mode: Mode): string;
17
+ //# sourceMappingURL=decisions.d.ts.map