@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.
@@ -0,0 +1,60 @@
1
+ import { Decision, Mode, Outcome } from '@magield/proto';
2
+ /**
3
+ * The only place two spellings of the same thing exist.
4
+ *
5
+ * The wire uses fully prefixed enum names — `DECISION_ALLOW_FLAGGED`,
6
+ * `OUTCOME_LOGIN_SUCCESS` — and `docs/api.md` §5 publishes the short lower-case
7
+ * form to integrators. Both are right for their audience and neither should
8
+ * leak into the other's, so the translation happens once, here.
9
+ *
10
+ * The generated `decisionToJSON` and `outcomeToJSON` are deliberately not used.
11
+ * They **throw** on a value they do not recognise, which is the wrong behaviour
12
+ * for a client: a gateway one minor version ahead that adds a decision would
13
+ * crash an older SDK rather than degrade. Additive changes within `v1` are
14
+ * promised (`docs/api.md` §7), so an unknown decision is a thing that will
15
+ * happen, and the safe reading of one is the most conservative neighbour rather
16
+ * than an exception thrown into somebody's sign-in.
17
+ */
18
+ const DECISIONS = new Map([
19
+ [Decision.DECISION_ALLOW, 'allow'],
20
+ [Decision.DECISION_ALLOW_FLAGGED, 'allow_flagged'],
21
+ [Decision.DECISION_CHALLENGE, 'challenge'],
22
+ [Decision.DECISION_STEP_UP, 'step_up'],
23
+ [Decision.DECISION_THROTTLE, 'throttle'],
24
+ [Decision.DECISION_DENY, 'deny'],
25
+ [Decision.DECISION_BAN, 'ban'],
26
+ ]);
27
+ /**
28
+ * Reads a decision off the wire.
29
+ *
30
+ * `DECISION_UNSPECIFIED` and anything this build has never heard of both become
31
+ * `allow_flagged`: let it through, and record that something was there. Mapping
32
+ * an unknown decision to `allow` would discard a signal the gateway meant to
33
+ * send; mapping it to `deny` would block real people because a server was
34
+ * upgraded first. `allow_flagged` is the reading that is wrong in the smallest
35
+ * way in either direction.
36
+ */
37
+ export function decisionName(decision) {
38
+ return DECISIONS.get(decision) ?? 'allow_flagged';
39
+ }
40
+ const OUTCOMES = new Map([
41
+ ['login_success', Outcome.OUTCOME_LOGIN_SUCCESS],
42
+ ['login_failure', Outcome.OUTCOME_LOGIN_FAILURE],
43
+ ['mfa_success', Outcome.OUTCOME_MFA_SUCCESS],
44
+ ['mfa_failure', Outcome.OUTCOME_MFA_FAILURE],
45
+ ['confirmed_good', Outcome.OUTCOME_CONFIRMED_GOOD],
46
+ ['confirmed_fraud', Outcome.OUTCOME_CONFIRMED_FRAUD],
47
+ ['chargeback', Outcome.OUTCOME_CHARGEBACK],
48
+ ['manual_review_good', Outcome.OUTCOME_MANUAL_REVIEW_GOOD],
49
+ ['manual_review_bad', Outcome.OUTCOME_MANUAL_REVIEW_BAD],
50
+ ['user_complaint', Outcome.OUTCOME_USER_COMPLAINT],
51
+ ['support_unblock', Outcome.OUTCOME_SUPPORT_UNBLOCK],
52
+ ]);
53
+ export function outcomeValue(outcome) {
54
+ return OUTCOMES.get(outcome) ?? Outcome.OUTCOME_UNSPECIFIED;
55
+ }
56
+ /** For display and logging only. Never compared against. */
57
+ export function modeName(mode) {
58
+ return (Mode[mode] ?? 'MODE_UNSPECIFIED').replace(/^MODE_/, '').toLowerCase();
59
+ }
60
+ //# sourceMappingURL=decisions.js.map
@@ -0,0 +1,69 @@
1
+ import type { ShieldClient } from './client.js';
2
+ import type { EvaluateInput } from './types.js';
3
+ /**
4
+ * Express middleware.
5
+ *
6
+ * Opt-in and explicit, per `PLAN.md` §4.2, and behind its own subpath so
7
+ * importing the client pulls none of this in.
8
+ *
9
+ * **It enforces nothing.** It scores the request, attaches the verdict, and
10
+ * calls `next()`. That is not laziness — it is the product's central claim:
11
+ * the engine advises and the integrator enforces, because what a denial should
12
+ * do to *your* sign-in page is a decision only you can make. A middleware that
13
+ * returned 403 on its own would be taking it.
14
+ *
15
+ * ## Why the types are structural
16
+ *
17
+ * Express is not a dependency of this package, and nor are its types. The three
18
+ * properties used here are the three declared below, so this works against
19
+ * Express 4 and 5 without pinning either, and a partner on a fork or a
20
+ * compatible router is not excluded by a version range we had no reason to
21
+ * pick.
22
+ */
23
+ /** The three things this middleware touches. */
24
+ export interface ExpressRequestLike {
25
+ readonly body?: unknown;
26
+ readonly ip?: string | undefined;
27
+ readonly headers: Record<string, string | string[] | undefined>;
28
+ [key: string]: unknown;
29
+ }
30
+ export type ExpressNext = (err?: unknown) => void;
31
+ export interface ShieldMiddlewareOptions {
32
+ readonly action: string;
33
+ /** Pulls the sealed token off the request. Defaults to `body.shieldToken`. */
34
+ readonly token?: (req: ExpressRequestLike) => string | undefined;
35
+ /** Business identifiers for this request. */
36
+ readonly keys?: (req: ExpressRequestLike) => EvaluateInput['keys'];
37
+ /**
38
+ * Where to hang the verdict. `req.shield` by default.
39
+ *
40
+ * Named rather than fixed because a property this middleware invents on
41
+ * somebody else's request object can collide, and silently.
42
+ */
43
+ readonly property?: string;
44
+ }
45
+ /**
46
+ * Scores the request and attaches the verdict.
47
+ *
48
+ * ```ts
49
+ * app.post('/login', shieldExpress(shield, {
50
+ * action: 'login',
51
+ * keys: (req) => ({ username: (req.body as { username?: string }).username }),
52
+ * }), (req, res) => {
53
+ * const verdict = (req as { shield: Verdict }).shield
54
+ * if (verdict.decision === 'deny') return res.status(401).json({ error: 'try again' })
55
+ * if (verdict.decision === 'challenge') return res.status(401).json({ challenge: verdict.challenge })
56
+ * // …sign them in, then: await verdict.reportOutcome('login_success')
57
+ * })
58
+ * ```
59
+ */
60
+ export declare function shieldExpress(shield: ShieldClient, options: ShieldMiddlewareOptions): (req: ExpressRequestLike, res: unknown, next: ExpressNext) => void;
61
+ /**
62
+ * Flattens the header bag.
63
+ *
64
+ * Node gives repeated headers as an array; the wire wants one string each. The
65
+ * order is preserved because header *order* is itself a signal — a browser
66
+ * sends its headers in a stable order and a script usually does not.
67
+ */
68
+ export declare function stringHeaders(headers: Record<string, string | string[] | undefined>): Record<string, string>;
69
+ //# sourceMappingURL=express.d.ts.map
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Scores the request and attaches the verdict.
3
+ *
4
+ * ```ts
5
+ * app.post('/login', shieldExpress(shield, {
6
+ * action: 'login',
7
+ * keys: (req) => ({ username: (req.body as { username?: string }).username }),
8
+ * }), (req, res) => {
9
+ * const verdict = (req as { shield: Verdict }).shield
10
+ * if (verdict.decision === 'deny') return res.status(401).json({ error: 'try again' })
11
+ * if (verdict.decision === 'challenge') return res.status(401).json({ challenge: verdict.challenge })
12
+ * // …sign them in, then: await verdict.reportOutcome('login_success')
13
+ * })
14
+ * ```
15
+ */
16
+ export function shieldExpress(shield, options) {
17
+ const property = options.property ?? 'shield';
18
+ return (req, _res, next) => {
19
+ // `void` rather than `async`: an express middleware that returns a promise
20
+ // has its rejection swallowed by the router, and `evaluate` does not reject
21
+ // anyway — a failure arrives as a degraded verdict.
22
+ void shield
23
+ .evaluate({
24
+ action: options.action,
25
+ ...(defaultToken(req, options) === undefined ? {} : { token: defaultToken(req, options) }),
26
+ ...(options.keys === undefined ? {} : { keys: options.keys(req) }),
27
+ ...(req.ip === undefined ? {} : { ip: req.ip }),
28
+ network: { headers: stringHeaders(req.headers) },
29
+ })
30
+ .then((verdict) => {
31
+ ;
32
+ req[property] = verdict;
33
+ next();
34
+ });
35
+ };
36
+ }
37
+ function defaultToken(req, options) {
38
+ if (options.token !== undefined)
39
+ return options.token(req);
40
+ const body = req.body;
41
+ return typeof body?.shieldToken === 'string' ? body.shieldToken : undefined;
42
+ }
43
+ /**
44
+ * Flattens the header bag.
45
+ *
46
+ * Node gives repeated headers as an array; the wire wants one string each. The
47
+ * order is preserved because header *order* is itself a signal — a browser
48
+ * sends its headers in a stable order and a script usually does not.
49
+ */
50
+ export function stringHeaders(headers) {
51
+ const flat = {};
52
+ for (const [name, value] of Object.entries(headers)) {
53
+ if (value === undefined)
54
+ continue;
55
+ flat[name] = Array.isArray(value) ? value.join(', ') : value;
56
+ }
57
+ return flat;
58
+ }
59
+ //# sourceMappingURL=express.js.map
@@ -0,0 +1,39 @@
1
+ import type { ShieldClient } from './client.js';
2
+ import type { EvaluateInput } from './types.js';
3
+ /**
4
+ * A Fastify preHandler.
5
+ *
6
+ * Same contract as the Express adapter and the same reasoning: it scores,
7
+ * attaches, and enforces nothing. Fastify is not a dependency and its types are
8
+ * not imported — the four properties used are declared below, so this works
9
+ * against Fastify 4 and 5 alike.
10
+ *
11
+ * A `preHandler` rather than a plugin, deliberately. A plugin would have to
12
+ * decorate the request and register hooks for routes it cannot know about; a
13
+ * preHandler is attached to the one route that needs it, which is where the
14
+ * decision about what to score belongs.
15
+ */
16
+ export interface FastifyRequestLike {
17
+ readonly body?: unknown;
18
+ readonly ip?: string;
19
+ readonly headers: Record<string, string | string[] | undefined>;
20
+ [key: string]: unknown;
21
+ }
22
+ export interface ShieldPreHandlerOptions {
23
+ readonly action: string;
24
+ readonly token?: (req: FastifyRequestLike) => string | undefined;
25
+ readonly keys?: (req: FastifyRequestLike) => EvaluateInput['keys'];
26
+ /** Where to hang the verdict. `request.shield` by default. */
27
+ readonly property?: string;
28
+ }
29
+ /**
30
+ * ```ts
31
+ * fastify.post('/login', { preHandler: shieldFastify(shield, { action: 'login' }) }, async (req, reply) => {
32
+ * const verdict = (req as { shield: Verdict }).shield
33
+ * if (verdict.decision === 'deny') return reply.code(401).send({ error: 'try again' })
34
+ * …
35
+ * })
36
+ * ```
37
+ */
38
+ export declare function shieldFastify(shield: ShieldClient, options: ShieldPreHandlerOptions): (req: FastifyRequestLike) => Promise<void>;
39
+ //# sourceMappingURL=fastify.d.ts.map
@@ -0,0 +1,29 @@
1
+ import { stringHeaders } from './express.js';
2
+ /**
3
+ * ```ts
4
+ * fastify.post('/login', { preHandler: shieldFastify(shield, { action: 'login' }) }, async (req, reply) => {
5
+ * const verdict = (req as { shield: Verdict }).shield
6
+ * if (verdict.decision === 'deny') return reply.code(401).send({ error: 'try again' })
7
+ * …
8
+ * })
9
+ * ```
10
+ */
11
+ export function shieldFastify(shield, options) {
12
+ const property = options.property ?? 'shield';
13
+ return async (req) => {
14
+ const token = options.token !== undefined ? options.token(req) : bodyToken(req);
15
+ const verdict = await shield.evaluate({
16
+ action: options.action,
17
+ ...(token === undefined ? {} : { token }),
18
+ ...(options.keys === undefined ? {} : { keys: options.keys(req) }),
19
+ ...(req.ip === undefined ? {} : { ip: req.ip }),
20
+ network: { headers: stringHeaders(req.headers) },
21
+ });
22
+ req[property] = verdict;
23
+ };
24
+ }
25
+ function bodyToken(req) {
26
+ const body = req.body;
27
+ return typeof body?.shieldToken === 'string' ? body.shieldToken : undefined;
28
+ }
29
+ //# sourceMappingURL=fastify.js.map
@@ -0,0 +1,7 @@
1
+ export { createClient, ShieldClient, API_KEY_METADATA } from './client.js';
2
+ export type { ShieldConfig, ShieldDefaults } from './client.js';
3
+ export { passwordPrefix, isPasswordPrefix } from './password.js';
4
+ export { Breaker } from './breaker.js';
5
+ export type { BreakerOptions } from './breaker.js';
6
+ export type { ChallengeOffer, ChallengeSolution, DecisionName, EvaluateInput, FeedbackInput, Identifiers, LimitCheckInput, LimitState, LimitVerdict, NetworkContext, OutcomeDetails, OutcomeName, Reason, TrustDeviceInput, TrustedDevice, Verdict, WouldHave, } from './types.js';
7
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { createClient, ShieldClient, API_KEY_METADATA } from './client.js';
2
+ export { passwordPrefix, isPasswordPrefix } from './password.js';
3
+ export { Breaker } from './breaker.js';
4
+ //# sourceMappingURL=index.js.map
package/dist/nest.d.ts ADDED
@@ -0,0 +1,45 @@
1
+ import type { ShieldClient } from './client.js';
2
+ import type { EvaluateInput } from './types.js';
3
+ /**
4
+ * A NestJS guard.
5
+ *
6
+ * Nest is not a dependency and there are no decorators here, which is
7
+ * deliberate rather than a shortcut: a guard is structurally just an object
8
+ * with `canActivate`, and `@nestjs/common` would add a large dependency to buy
9
+ * a decorator the integrator can apply themselves with `@UseGuards(...)`.
10
+ *
11
+ * **It always returns true.** A Nest guard that returned false would send a
12
+ * 403 the integrator did not choose, which is precisely the enforcement this
13
+ * product leaves to them. The guard's job is to put the verdict on the request;
14
+ * the controller decides.
15
+ */
16
+ interface ExecutionContextLike {
17
+ switchToHttp(): {
18
+ getRequest<T = Record<string, unknown>>(): T;
19
+ };
20
+ }
21
+ export interface ShieldGuardOptions {
22
+ readonly action: string;
23
+ readonly token?: (req: Record<string, unknown>) => string | undefined;
24
+ readonly keys?: (req: Record<string, unknown>) => EvaluateInput['keys'];
25
+ /** Where to hang the verdict. `request.shield` by default. */
26
+ readonly property?: string;
27
+ }
28
+ export interface ShieldGuard {
29
+ canActivate(context: ExecutionContextLike): Promise<boolean>;
30
+ }
31
+ /**
32
+ * ```ts
33
+ * const guard = createShieldGuard(shield, { action: 'login' })
34
+ *
35
+ * @UseGuards(guard)
36
+ * @Post('login')
37
+ * async login(@Req() req: Request & { shield: Verdict }) {
38
+ * if (req.shield.decision === 'deny') throw new UnauthorizedException()
39
+ * …
40
+ * }
41
+ * ```
42
+ */
43
+ export declare function createShieldGuard(shield: ShieldClient, options: ShieldGuardOptions): ShieldGuard;
44
+ export {};
45
+ //# sourceMappingURL=nest.d.ts.map
package/dist/nest.js ADDED
@@ -0,0 +1,39 @@
1
+ import { stringHeaders } from './express.js';
2
+ /**
3
+ * ```ts
4
+ * const guard = createShieldGuard(shield, { action: 'login' })
5
+ *
6
+ * @UseGuards(guard)
7
+ * @Post('login')
8
+ * async login(@Req() req: Request & { shield: Verdict }) {
9
+ * if (req.shield.decision === 'deny') throw new UnauthorizedException()
10
+ * …
11
+ * }
12
+ * ```
13
+ */
14
+ export function createShieldGuard(shield, options) {
15
+ const property = options.property ?? 'shield';
16
+ return {
17
+ async canActivate(context) {
18
+ const req = context.switchToHttp().getRequest();
19
+ const headers = (req['headers'] ?? {});
20
+ const ip = typeof req['ip'] === 'string' ? req['ip'] : undefined;
21
+ const token = options.token !== undefined ? options.token(req) : bodyToken(req);
22
+ const verdict = await shield.evaluate({
23
+ action: options.action,
24
+ ...(token === undefined ? {} : { token }),
25
+ ...(options.keys === undefined ? {} : { keys: options.keys(req) }),
26
+ ...(ip === undefined ? {} : { ip }),
27
+ network: { headers: stringHeaders(headers) },
28
+ });
29
+ req[property] = verdict;
30
+ // Never false. See the note above.
31
+ return true;
32
+ },
33
+ };
34
+ }
35
+ function bodyToken(req) {
36
+ const body = req['body'];
37
+ return typeof body?.shieldToken === 'string' ? body.shieldToken : undefined;
38
+ }
39
+ //# sourceMappingURL=nest.js.map
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The first five hex characters of a password's SHA-1.
3
+ *
4
+ * `docs/rate-limiting.md` §175 promises this helper "so nobody hand-rolls the
5
+ * computation", and hand-rolling it is genuinely dangerous: the whole point is
6
+ * that five characters identify a *campaign* and identify no *person*. Send six
7
+ * and the space narrows by sixteen; send the full digest and you have shipped a
8
+ * password hash to a service that never asked for one.
9
+ *
10
+ * It is k-anonymity, the same construction Have I Been Pwned's range API uses.
11
+ * Five hex characters is one bucket in about a million, which is enough to see
12
+ * one password being sprayed across many accounts and far too coarse to say
13
+ * whose it was.
14
+ *
15
+ * **This is the only place the guardrail exists.** Both this document and the
16
+ * proto comment on `password_sha1_prefix` say the gateway rejects anything that
17
+ * is not exactly five lowercase hex characters. It does not — `services/
18
+ * context.ts` passes the field through on a truthiness check. Until that is
19
+ * fixed server-side, a caller that builds the prefix by hand can send whatever
20
+ * it likes and nothing will stop it.
21
+ */
22
+ export declare function passwordPrefix(password: string): string;
23
+ /** Whether a value is a prefix this SDK would have produced. */
24
+ export declare function isPasswordPrefix(value: string): boolean;
25
+ //# sourceMappingURL=password.d.ts.map
@@ -0,0 +1,30 @@
1
+ import { createHash } from 'node:crypto';
2
+ /**
3
+ * The first five hex characters of a password's SHA-1.
4
+ *
5
+ * `docs/rate-limiting.md` §175 promises this helper "so nobody hand-rolls the
6
+ * computation", and hand-rolling it is genuinely dangerous: the whole point is
7
+ * that five characters identify a *campaign* and identify no *person*. Send six
8
+ * and the space narrows by sixteen; send the full digest and you have shipped a
9
+ * password hash to a service that never asked for one.
10
+ *
11
+ * It is k-anonymity, the same construction Have I Been Pwned's range API uses.
12
+ * Five hex characters is one bucket in about a million, which is enough to see
13
+ * one password being sprayed across many accounts and far too coarse to say
14
+ * whose it was.
15
+ *
16
+ * **This is the only place the guardrail exists.** Both this document and the
17
+ * proto comment on `password_sha1_prefix` say the gateway rejects anything that
18
+ * is not exactly five lowercase hex characters. It does not — `services/
19
+ * context.ts` passes the field through on a truthiness check. Until that is
20
+ * fixed server-side, a caller that builds the prefix by hand can send whatever
21
+ * it likes and nothing will stop it.
22
+ */
23
+ export function passwordPrefix(password) {
24
+ return createHash('sha1').update(password, 'utf8').digest('hex').slice(0, 5);
25
+ }
26
+ /** Whether a value is a prefix this SDK would have produced. */
27
+ export function isPasswordPrefix(value) {
28
+ return /^[0-9a-f]{5}$/.test(value);
29
+ }
30
+ //# sourceMappingURL=password.js.map