@magield/node 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.
- package/LICENSE +27 -0
- package/dist/breaker.d.ts +41 -0
- package/dist/breaker.js +91 -0
- package/dist/client.d.ts +100 -0
- package/dist/client.js +407 -0
- package/dist/decisions.d.ts +17 -0
- package/dist/decisions.js +60 -0
- package/dist/express.d.ts +69 -0
- package/dist/express.js +59 -0
- package/dist/fastify.d.ts +39 -0
- package/dist/fastify.js +29 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +4 -0
- package/dist/nest.d.ts +45 -0
- package/dist/nest.js +39 -0
- package/dist/password.d.ts +25 -0
- package/dist/password.js +30 -0
- package/dist/types.d.ts +273 -0
- package/dist/types.js +16 -0
- package/package.json +39 -3
- package/README.md +0 -3
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
|
package/dist/breaker.js
ADDED
|
@@ -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
|
package/dist/client.d.ts
ADDED
|
@@ -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
|