@noresponse/node 1.0.0 → 1.0.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/README.md +1 -1
- package/dist/auth.d.ts +11 -0
- package/dist/auth.js +111 -0
- package/dist/generated/models.d.ts +61 -3
- package/dist/generated/models.js +1 -1
- package/dist/index.d.ts +49 -12
- package/dist/index.js +164 -46
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ if (assessment.status === "pending") {
|
|
|
24
24
|
}
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
Also provides `getAssessment(assessmentId, { responseMode })
|
|
27
|
+
Also provides `getAssessment(assessmentId, { responseMode })`, `queryAssessments({ account_id, limit, cursor, responseMode })` and `recordOutcome(assessmentId, { outcome, idempotencyKey, observedAt, externalRef, notes })`, which reports what was later learned about the account (`confirmed_abuse`, `account_takeover`, `chargeback`, `false_positive`, `legitimate` or `unknown`). `responseMode: "detailed"` (with the `detailed` key scope) adds `risk.details` and `signals[]`; `raw` is refused. Pass `flow` (`signup`, `login`, `recovery`, or `profile_change`) and an uppercase `region` when the event is not the default signup. The client retries network failures, 429 and 503 a bounded number of times while preserving the same idempotency key.
|
|
28
28
|
|
|
29
29
|
Webhooks: `verifyWebhook(rawBody, headerPairs, secrets, nowSeconds?)` checks the `v1=` HMAC-SHA256 signature over the exact raw request bytes, the 300-second timestamp window and the event id, and returns the typed `WebhookEvent`. It takes **raw header pairs** so it can refuse a repeated header, including a case variant. Never parse then re-stringify the body before verifying. An `assessment.updated` event's `data` is the full assessment, exactly as `getAssessment` returns it: `webhookAssessment(event)` returns it as a typed `Assessment`. Keep the highest `assessment_version` per assessment. When receiving `endpoint.verification`, respond with `webhookVerificationResponse(event)`.
|
|
30
30
|
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { AssessmentRequestAuthContext } from "./generated/models.js";
|
|
2
|
+
export type VerifiedClaims = Record<string, unknown>;
|
|
3
|
+
export type AuthContextOverrides = Partial<AssessmentRequestAuthContext>;
|
|
4
|
+
/** Map verified WorkOS/AuthKit claims plus verified user/session-record overrides. */
|
|
5
|
+
export declare function fromWorkOS(claims: VerifiedClaims, overrides?: AuthContextOverrides): AssessmentRequestAuthContext;
|
|
6
|
+
/** Map verified Clerk session claims. Pass Session.createdAt as session_created_at. */
|
|
7
|
+
export declare function fromClerk(claims: VerifiedClaims, overrides?: AuthContextOverrides): AssessmentRequestAuthContext;
|
|
8
|
+
/** Map verified Auth0 ID-token, userinfo, or normalized session claims. Do not pass an opaque API access token. */
|
|
9
|
+
export declare function fromAuth0(claims: VerifiedClaims, overrides?: AuthContextOverrides): AssessmentRequestAuthContext;
|
|
10
|
+
/** Generic OIDC adapter for providers without a first-class helper. */
|
|
11
|
+
export declare function fromOIDC(provider: string, claims: VerifiedClaims, overrides?: AuthContextOverrides): AssessmentRequestAuthContext;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
function text(value) {
|
|
2
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
3
|
+
}
|
|
4
|
+
function iso(value) {
|
|
5
|
+
if (typeof value === "string")
|
|
6
|
+
return value;
|
|
7
|
+
// Whole UTC seconds (the fraction is truncated), as the Go and Python helpers write it.
|
|
8
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
9
|
+
return new Date(Math.trunc(value) * 1000).toISOString().replace(".000Z", "Z");
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
// Defaults are the auth-context.schema.json bounds for roles and scopes (32
|
|
13
|
+
// items of at most 64 characters); audiences are 8 x 256 and amr 8 x 32. The
|
|
14
|
+
// Go and Python helpers apply the same caps. Over-long values are dropped,
|
|
15
|
+
// not truncated.
|
|
16
|
+
function bounded(values, limit, maxLength) {
|
|
17
|
+
const kept = values
|
|
18
|
+
.filter((item) => typeof item === "string" && item.length > 0 && [...item].length <= maxLength)
|
|
19
|
+
.slice(0, limit);
|
|
20
|
+
return kept.length ? kept : undefined;
|
|
21
|
+
}
|
|
22
|
+
function strings(value, limit = 32, maxLength = 64) {
|
|
23
|
+
if (typeof value === "string")
|
|
24
|
+
return bounded(value.split(/\s+/), limit, maxLength);
|
|
25
|
+
return Array.isArray(value) ? bounded(value, limit, maxLength) : undefined;
|
|
26
|
+
}
|
|
27
|
+
function delimitedStrings(value, limit = 32, maxLength = 64) {
|
|
28
|
+
if (typeof value !== "string")
|
|
29
|
+
return strings(value, limit, maxLength);
|
|
30
|
+
return bounded(value.split(/[\s,]+/).map(item => item.trim()), limit, maxLength);
|
|
31
|
+
}
|
|
32
|
+
function audiences(value) {
|
|
33
|
+
return strings(value, 8, 256);
|
|
34
|
+
}
|
|
35
|
+
function object(value) {
|
|
36
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : undefined;
|
|
37
|
+
}
|
|
38
|
+
function actorPresent(value) {
|
|
39
|
+
const record = object(value);
|
|
40
|
+
return Boolean(record ? text(record.sub) ?? text(record.email) : text(value));
|
|
41
|
+
}
|
|
42
|
+
function base(provider, claims, overrides = {}, options = {}) {
|
|
43
|
+
const issuer = text(overrides.issuer) ?? text(claims.iss);
|
|
44
|
+
const subject = text(overrides.subject) ?? text(claims.sub);
|
|
45
|
+
if (!issuer || !subject)
|
|
46
|
+
throw new Error("NoResponse auth context requires verified issuer and subject");
|
|
47
|
+
const claimAudiences = audiences(claims.aud);
|
|
48
|
+
const mergedAudiences = overrides.audiences ?? claimAudiences;
|
|
49
|
+
return {
|
|
50
|
+
provider,
|
|
51
|
+
issuer,
|
|
52
|
+
subject,
|
|
53
|
+
...(mergedAudiences?.length ? { audiences: mergedAudiences, audience: overrides.audience ?? mergedAudiences[0] } : {}),
|
|
54
|
+
...(options.includeIssuedAt !== false && iso(claims.iat) ? { issued_at: iso(claims.iat) } : {}),
|
|
55
|
+
...(iso(claims.exp) ? { expires_at: iso(claims.exp) } : {}),
|
|
56
|
+
...(iso(claims.auth_time) ? { auth_time: iso(claims.auth_time) } : {}),
|
|
57
|
+
...(text(claims.sid) ? { session_id: text(claims.sid) } : {}),
|
|
58
|
+
...(text(claims.jti) ? { token_id: text(claims.jti) } : {}),
|
|
59
|
+
verified: true,
|
|
60
|
+
transport: "backend",
|
|
61
|
+
...overrides,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/** Map verified WorkOS/AuthKit claims plus verified user/session-record overrides. */
|
|
65
|
+
export function fromWorkOS(claims, overrides = {}) {
|
|
66
|
+
const defaults = {
|
|
67
|
+
organization_id: text(claims.org_id),
|
|
68
|
+
roles: strings(claims.roles ?? claims.role),
|
|
69
|
+
scopes: strings(claims.permissions),
|
|
70
|
+
impersonated: actorPresent(claims.act),
|
|
71
|
+
};
|
|
72
|
+
return base("workos", claims, { ...defaults, ...overrides });
|
|
73
|
+
}
|
|
74
|
+
/** Map verified Clerk session claims. Pass Session.createdAt as session_created_at. */
|
|
75
|
+
export function fromClerk(claims, overrides = {}) {
|
|
76
|
+
const org = object(claims.o);
|
|
77
|
+
const secondFactorAgeMinutes = Array.isArray(claims.fva) && typeof claims.fva[1] === "number" && Number.isFinite(claims.fva[1]) ? claims.fva[1] : undefined;
|
|
78
|
+
const mfa = secondFactorAgeMinutes !== undefined && secondFactorAgeMinutes >= 0
|
|
79
|
+
? { present: true, age_seconds: Math.trunc(secondFactorAgeMinutes * 60) }
|
|
80
|
+
: undefined;
|
|
81
|
+
const defaults = {
|
|
82
|
+
organization_id: text(org?.id) ?? text(claims.org_id),
|
|
83
|
+
// Clerk's role claim is one role slug, never a list.
|
|
84
|
+
roles: bounded([org?.rol], 32, 64) ?? bounded([claims.org_role], 32, 64),
|
|
85
|
+
scopes: delimitedStrings(org?.per) ?? delimitedStrings(claims.org_permissions),
|
|
86
|
+
impersonated: actorPresent(claims.act),
|
|
87
|
+
...(mfa ? { mfa } : {}),
|
|
88
|
+
};
|
|
89
|
+
// iat is a 60-second token refresh clock, so it is not sent as issued_at; session age is the session_created_at override.
|
|
90
|
+
return base("clerk", claims, { ...defaults, ...overrides }, { includeIssuedAt: false });
|
|
91
|
+
}
|
|
92
|
+
/** Map verified Auth0 ID-token, userinfo, or normalized session claims. Do not pass an opaque API access token. */
|
|
93
|
+
export function fromAuth0(claims, overrides = {}) {
|
|
94
|
+
const amr = strings(claims.amr, 8, 32);
|
|
95
|
+
const mfaMethods = amr?.filter(method => method.toLowerCase() !== "pwd");
|
|
96
|
+
const defaults = {
|
|
97
|
+
organization_id: text(claims.org_id),
|
|
98
|
+
scopes: strings(claims.scope) ?? strings(claims.permissions),
|
|
99
|
+
email_verified: typeof claims.email_verified === "boolean" ? claims.email_verified : undefined,
|
|
100
|
+
phone_verified: typeof claims.phone_number_verified === "boolean" ? claims.phone_number_verified : undefined,
|
|
101
|
+
impersonated: actorPresent(claims.act),
|
|
102
|
+
...(mfaMethods?.length ? { mfa: { present: amr?.some(method => method.toLowerCase() === "mfa") ?? false, methods: mfaMethods } } : {}),
|
|
103
|
+
};
|
|
104
|
+
return base("auth0", claims, { ...defaults, ...overrides });
|
|
105
|
+
}
|
|
106
|
+
/** Generic OIDC adapter for providers without a first-class helper. */
|
|
107
|
+
export function fromOIDC(provider, claims, overrides = {}) {
|
|
108
|
+
if (!/^[a-z0-9][a-z0-9._-]{1,31}$/.test(provider))
|
|
109
|
+
throw new Error("provider must be a lowercase provider slug");
|
|
110
|
+
return base(provider, claims, overrides);
|
|
111
|
+
}
|
|
@@ -5,6 +5,8 @@ export type AssessmentRequest = {
|
|
|
5
5
|
request_id?: string;
|
|
6
6
|
deadline_ms?: number;
|
|
7
7
|
response_mode?: AssessmentRequestResponseMode;
|
|
8
|
+
client_context?: AssessmentRequestClientContext;
|
|
9
|
+
auth_context?: AssessmentRequestAuthContext;
|
|
8
10
|
};
|
|
9
11
|
export type AssessmentRequestIdentifiers = {
|
|
10
12
|
email?: string;
|
|
@@ -19,6 +21,50 @@ export type AssessmentRequestPolicyContext = {
|
|
|
19
21
|
};
|
|
20
22
|
export type AssessmentRequestPolicyContextFlow = "signup" | "login" | "recovery" | "profile_change";
|
|
21
23
|
export type AssessmentRequestResponseMode = "standard" | "detailed";
|
|
24
|
+
export type AssessmentRequestClientContext = {
|
|
25
|
+
timezone?: string;
|
|
26
|
+
locale?: string;
|
|
27
|
+
platform?: AssessmentRequestClientContextPlatform;
|
|
28
|
+
app_version?: string;
|
|
29
|
+
user_agent?: string;
|
|
30
|
+
device_id?: string;
|
|
31
|
+
session_id?: string;
|
|
32
|
+
form_duration_ms?: number;
|
|
33
|
+
client_observed_at?: string;
|
|
34
|
+
};
|
|
35
|
+
export type AssessmentRequestClientContextPlatform = "web" | "ios" | "android" | "other";
|
|
36
|
+
export type AssessmentRequestAuthContext = {
|
|
37
|
+
provider: string;
|
|
38
|
+
issuer: string;
|
|
39
|
+
audience?: string;
|
|
40
|
+
audiences?: string[];
|
|
41
|
+
subject: string;
|
|
42
|
+
session_id?: string;
|
|
43
|
+
organization_id?: string;
|
|
44
|
+
tenant_id?: string;
|
|
45
|
+
token_id?: string;
|
|
46
|
+
issued_at?: string;
|
|
47
|
+
expires_at?: string;
|
|
48
|
+
session_created_at?: string;
|
|
49
|
+
auth_time?: string;
|
|
50
|
+
account_created_at?: string;
|
|
51
|
+
email_verified?: boolean;
|
|
52
|
+
phone_verified?: boolean;
|
|
53
|
+
assurance_level?: AssessmentRequestAuthContextAssuranceLevel;
|
|
54
|
+
impersonated?: boolean;
|
|
55
|
+
transport?: AssessmentRequestAuthContextTransport;
|
|
56
|
+
verified: boolean;
|
|
57
|
+
mfa?: AssessmentRequestAuthContextMfa;
|
|
58
|
+
roles?: string[];
|
|
59
|
+
scopes?: string[];
|
|
60
|
+
};
|
|
61
|
+
export type AssessmentRequestAuthContextAssuranceLevel = "unknown" | "low" | "medium" | "high";
|
|
62
|
+
export type AssessmentRequestAuthContextTransport = "backend" | "browser";
|
|
63
|
+
export type AssessmentRequestAuthContextMfa = {
|
|
64
|
+
present?: boolean;
|
|
65
|
+
methods?: string[];
|
|
66
|
+
age_seconds?: number;
|
|
67
|
+
};
|
|
22
68
|
export type Assessment = {
|
|
23
69
|
schema_version: "1";
|
|
24
70
|
assessment_id: string;
|
|
@@ -61,8 +107,8 @@ export type RiskReason = {
|
|
|
61
107
|
weight: number;
|
|
62
108
|
identifier?: ReasonIdentifier;
|
|
63
109
|
};
|
|
64
|
-
export type ReasonCode = "no_digital_footprint" | "voip_number" | "disposable_email" | "new_email" | "spam_reports" | "fraud_flag" | "identity_mismatch" | "country_mismatch" | "proxy_ip" | "seen_before" | "shared_ip" | "many_recent_signups" | "
|
|
65
|
-
export type ReasonIdentifier = "email" | "username" | "phone" | "name" | "ip";
|
|
110
|
+
export type ReasonCode = "no_digital_footprint" | "voip_number" | "disposable_email" | "new_email" | "spam_reports" | "fraud_flag" | "identity_mismatch" | "country_mismatch" | "proxy_ip" | "seen_before" | "shared_ip" | "many_recent_signups" | "established_accounts" | "aged_identity" | "breach_name_match" | "breach_cooccurrence" | "breach_name_mismatch" | "breach_phone_mismatch" | "recent_password_exposure" | "credential_list_exposure" | "abuse_ecosystem_member" | "no_breach_history" | "automation_fast_submit" | "client_clock_skew" | "bot_or_automation" | "auth_email_verified" | "auth_phone_verified" | "auth_mfa_recent" | "auth_new_session" | "auth_new_account" | "auth_impersonated" | "context_timezone_mismatch" | "context_locale_mismatch" | "context_line_country_mismatch" | "prior_abuse_link";
|
|
111
|
+
export type ReasonIdentifier = "email" | "username" | "phone" | "name" | "ip" | "context";
|
|
66
112
|
export type RiskSummary = {
|
|
67
113
|
phone?: RiskSummaryPhone;
|
|
68
114
|
email?: RiskSummaryEmail;
|
|
@@ -73,7 +119,6 @@ export type RiskSummary = {
|
|
|
73
119
|
export type RiskSummaryPhone = {
|
|
74
120
|
line_type?: RiskSummaryPhoneLineType;
|
|
75
121
|
country?: string;
|
|
76
|
-
on_whatsapp?: boolean;
|
|
77
122
|
report_count?: number;
|
|
78
123
|
carrier?: string;
|
|
79
124
|
};
|
|
@@ -246,3 +291,16 @@ export type AssessmentQueryResponseMode = "standard" | "detailed";
|
|
|
246
291
|
export type WebhookVerificationResponse = {
|
|
247
292
|
challenge: string;
|
|
248
293
|
};
|
|
294
|
+
export type AssessmentOutcomeRequest = {
|
|
295
|
+
outcome: Outcome;
|
|
296
|
+
observed_at?: string;
|
|
297
|
+
external_ref?: string;
|
|
298
|
+
notes?: string;
|
|
299
|
+
};
|
|
300
|
+
export type Outcome = "confirmed_abuse" | "account_takeover" | "chargeback" | "false_positive" | "legitimate" | "unknown";
|
|
301
|
+
export type AssessmentOutcome = {
|
|
302
|
+
id: string;
|
|
303
|
+
assessment_id: string;
|
|
304
|
+
outcome: Outcome;
|
|
305
|
+
received_at: string;
|
|
306
|
+
};
|
package/dist/generated/models.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
/* GENERATED FILE. Run python3 sdks/tools/generate_models.py to update. */
|
|
1
|
+
/* GENERATED FILE. Run python3 apps/identity-intelligence/sdks/tools/generate_models.py to update. */
|
|
2
2
|
export {};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,45 +1,82 @@
|
|
|
1
1
|
/** Server-side NoResponse signup client. Never expose the API key in browser code. */
|
|
2
|
-
import type { Assessment, AssessmentList, AssessmentQuery, AssessmentRequest } from "./generated/models.js";
|
|
2
|
+
import type { Assessment, AssessmentList, AssessmentOutcome, AssessmentQuery, AssessmentRequest, Outcome } from "./generated/models.js";
|
|
3
3
|
export type * from "./generated/models.js";
|
|
4
4
|
export { verifyWebhook, webhookAssessment, webhookVerificationResponse } from "./webhooks.js";
|
|
5
|
-
|
|
5
|
+
export { fromAuth0, fromClerk, fromOIDC, fromWorkOS } from "./auth.js";
|
|
6
6
|
export type ResponseMode = NonNullable<AssessmentRequest["response_mode"]>;
|
|
7
|
+
export type Flow = NonNullable<NonNullable<AssessmentRequest["policy_context"]>["flow"]>;
|
|
7
8
|
export declare class NoResponseError extends Error {
|
|
8
9
|
readonly status: number;
|
|
9
10
|
readonly type?: string | undefined;
|
|
10
11
|
readonly code?: string | undefined;
|
|
11
12
|
readonly detail?: string | undefined;
|
|
12
|
-
|
|
13
|
+
readonly requestId?: string;
|
|
14
|
+
readonly retryAfter?: number;
|
|
15
|
+
readonly retryable: boolean;
|
|
16
|
+
constructor(status: number, message: string, type?: string | undefined, code?: string | undefined, detail?: string | undefined, metadata?: {
|
|
17
|
+
requestId?: string;
|
|
18
|
+
retryAfter?: number;
|
|
19
|
+
});
|
|
13
20
|
}
|
|
21
|
+
export type NoResponseConfig = {
|
|
22
|
+
apiKey: string;
|
|
23
|
+
baseUrl: string;
|
|
24
|
+
fetch?: typeof fetch;
|
|
25
|
+
timeoutMs?: number;
|
|
26
|
+
/** Safe retries for network errors, 429 and 503. POST assessment retries reuse the same Idempotency-Key. */
|
|
27
|
+
maxRetries?: number;
|
|
28
|
+
/** Base delay for a transient retry when Retry-After is absent. */
|
|
29
|
+
retryBaseDelayMs?: number;
|
|
30
|
+
};
|
|
14
31
|
export declare class NoResponse {
|
|
15
32
|
private readonly config;
|
|
16
33
|
private readonly baseUrl;
|
|
17
|
-
constructor(config:
|
|
18
|
-
apiKey: string;
|
|
19
|
-
baseUrl: string;
|
|
20
|
-
fetch?: typeof fetch;
|
|
21
|
-
timeoutMs?: number;
|
|
22
|
-
});
|
|
34
|
+
constructor(config: NoResponseConfig);
|
|
23
35
|
assess(input: {
|
|
24
36
|
accountId: string;
|
|
25
37
|
email?: string;
|
|
26
38
|
username?: string;
|
|
27
39
|
phone?: string;
|
|
28
|
-
/** Full name as typed. Used only for matching; never looked up. Needs email, username or phone. */
|
|
29
40
|
name?: string;
|
|
30
|
-
/** Signup IPv4 or IPv6 address (public ranges only). Needs email, username or phone. */
|
|
31
41
|
ip?: string;
|
|
32
|
-
|
|
42
|
+
/** Customer event policy. Defaults to signup when omitted. */
|
|
43
|
+
flow?: Flow;
|
|
44
|
+
/** ISO 3166-1 alpha-2 region used for consistency checks. */
|
|
33
45
|
region?: string;
|
|
46
|
+
requestId?: string;
|
|
34
47
|
deadlineMs?: number;
|
|
35
48
|
responseMode?: ResponseMode;
|
|
49
|
+
/** Bounded, tenant-scoped client hints. Raw user agents and opaque IDs are never returned. */
|
|
50
|
+
clientContext?: AssessmentRequest["client_context"];
|
|
51
|
+
/** Normalized claims from a provider-verified backend session. Never pass a raw token. */
|
|
52
|
+
authContext?: AssessmentRequest["auth_context"];
|
|
36
53
|
idempotencyKey: string;
|
|
37
54
|
}): Promise<Assessment>;
|
|
38
55
|
getAssessment(assessmentId: string, options?: {
|
|
39
56
|
responseMode?: ResponseMode;
|
|
40
57
|
}): Promise<Assessment>;
|
|
58
|
+
private getAssessmentWithHeaders;
|
|
59
|
+
waitForAssessment(assessmentId: string, options?: {
|
|
60
|
+
responseMode?: ResponseMode;
|
|
61
|
+
timeoutMs?: number;
|
|
62
|
+
}): Promise<Assessment>;
|
|
41
63
|
queryAssessments(query: AssessmentQuery & {
|
|
42
64
|
responseMode?: ResponseMode;
|
|
43
65
|
}): Promise<AssessmentList>;
|
|
66
|
+
/**
|
|
67
|
+
* Report what was later learned about an assessed account. confirmed_abuse, account_takeover and
|
|
68
|
+
* chargeback report abuse; false_positive and legitimate withdraw this tenant's earlier report;
|
|
69
|
+
* unknown is recorded only. A retry with the same idempotencyKey and body answers the same record.
|
|
70
|
+
*/
|
|
71
|
+
recordOutcome(assessmentId: string, input: {
|
|
72
|
+
outcome: Outcome;
|
|
73
|
+
/** RFC 3339 time the provider observed the outcome; defaults to when NoResponse receives it. */
|
|
74
|
+
observedAt?: string;
|
|
75
|
+
externalRef?: string;
|
|
76
|
+
notes?: string;
|
|
77
|
+
idempotencyKey: string;
|
|
78
|
+
}): Promise<AssessmentOutcome>;
|
|
79
|
+
private validateMode;
|
|
44
80
|
private request;
|
|
81
|
+
private waitForRetry;
|
|
45
82
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,18 +1,30 @@
|
|
|
1
1
|
export { verifyWebhook, webhookAssessment, webhookVerificationResponse } from "./webhooks.js";
|
|
2
|
+
export { fromAuth0, fromClerk, fromOIDC, fromWorkOS } from "./auth.js";
|
|
2
3
|
const LOOKUP_IDENTIFIERS = ["email", "username", "phone"];
|
|
3
4
|
const MATCH_IDENTIFIERS = ["name", "ip"];
|
|
5
|
+
const IDEMPOTENCY = /^[A-Za-z0-9._:-]{16,128}$/;
|
|
6
|
+
const ASSESSMENT_ID = /^asm_[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
7
|
+
const OUTCOMES = ["confirmed_abuse", "account_takeover", "chargeback", "false_positive", "legitimate", "unknown"];
|
|
8
|
+
const EXTERNAL_REF = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
9
|
+
const RFC3339 = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?(?:Z|[+-][0-9]{2}:[0-9]{2})$/;
|
|
4
10
|
export class NoResponseError extends Error {
|
|
5
11
|
status;
|
|
6
12
|
type;
|
|
7
13
|
code;
|
|
8
14
|
detail;
|
|
9
|
-
|
|
15
|
+
requestId;
|
|
16
|
+
retryAfter;
|
|
17
|
+
retryable;
|
|
18
|
+
constructor(status, message, type, code, detail, metadata = {}) {
|
|
10
19
|
super(message);
|
|
11
20
|
this.status = status;
|
|
12
21
|
this.type = type;
|
|
13
22
|
this.code = code;
|
|
14
23
|
this.detail = detail;
|
|
15
24
|
this.name = "NoResponseError";
|
|
25
|
+
this.requestId = metadata.requestId;
|
|
26
|
+
this.retryAfter = metadata.retryAfter;
|
|
27
|
+
this.retryable = status === 429 || status === 503;
|
|
16
28
|
}
|
|
17
29
|
}
|
|
18
30
|
export class NoResponse {
|
|
@@ -25,10 +37,15 @@ export class NoResponse {
|
|
|
25
37
|
if (config.timeoutMs !== undefined && (!Number.isInteger(config.timeoutMs) || config.timeoutMs <= 0)) {
|
|
26
38
|
throw new Error("timeoutMs must be a positive integer");
|
|
27
39
|
}
|
|
40
|
+
if (config.maxRetries !== undefined && (!Number.isInteger(config.maxRetries) || config.maxRetries < 0)) {
|
|
41
|
+
throw new Error("maxRetries must be a non-negative integer");
|
|
42
|
+
}
|
|
43
|
+
if (config.retryBaseDelayMs !== undefined && (!Number.isInteger(config.retryBaseDelayMs) || config.retryBaseDelayMs < 0)) {
|
|
44
|
+
throw new Error("retryBaseDelayMs must be a non-negative integer");
|
|
45
|
+
}
|
|
28
46
|
const url = new URL(config.baseUrl);
|
|
29
|
-
if (url.search || url.hash || /[?#]$/.test(config.baseUrl))
|
|
47
|
+
if (url.search || url.hash || /[?#]$/.test(config.baseUrl))
|
|
30
48
|
throw new Error("baseUrl must not include a query or fragment");
|
|
31
|
-
}
|
|
32
49
|
if (url.protocol !== "https:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1") {
|
|
33
50
|
throw new Error("NoResponse requires HTTPS outside localhost");
|
|
34
51
|
}
|
|
@@ -46,41 +63,77 @@ export class NoResponse {
|
|
|
46
63
|
identifiers[key] = input[key];
|
|
47
64
|
if (input.name !== undefined && (input.name.length < 1 || input.name.length > 128))
|
|
48
65
|
throw new Error("name must be 1-128 characters");
|
|
49
|
-
if (
|
|
66
|
+
if (!IDEMPOTENCY.test(input.idempotencyKey))
|
|
50
67
|
throw new Error("Valid 16-128 character idempotencyKey required");
|
|
68
|
+
if (input.deadlineMs !== undefined && (!Number.isInteger(input.deadlineMs) || input.deadlineMs < 50 || input.deadlineMs > 500)) {
|
|
69
|
+
throw new Error("deadlineMs must be 50–500");
|
|
70
|
+
}
|
|
71
|
+
if (input.region !== undefined && !/^[A-Z]{2}$/.test(input.region))
|
|
72
|
+
throw new Error("region must be an uppercase ISO alpha-2 code");
|
|
73
|
+
if (input.responseMode !== undefined && input.responseMode !== "standard" && input.responseMode !== "detailed") {
|
|
74
|
+
throw new Error("responseMode must be standard or detailed; raw is never served");
|
|
75
|
+
}
|
|
51
76
|
const body = { account_id: input.accountId, identifiers: identifiers };
|
|
52
77
|
if (input.requestId !== undefined)
|
|
53
78
|
body.request_id = input.requestId;
|
|
54
|
-
if (input.deadlineMs !== undefined)
|
|
55
|
-
if (!Number.isInteger(input.deadlineMs) || input.deadlineMs < 50 || input.deadlineMs > 500)
|
|
56
|
-
throw new Error("deadlineMs must be 50–500");
|
|
79
|
+
if (input.deadlineMs !== undefined)
|
|
57
80
|
body.deadline_ms = input.deadlineMs;
|
|
81
|
+
if (input.flow !== undefined || input.region !== undefined) {
|
|
82
|
+
body.policy_context = { flow: input.flow ?? "signup", ...(input.region !== undefined ? { region: input.region } : {}) };
|
|
58
83
|
}
|
|
59
|
-
if (input.
|
|
60
|
-
body.policy_context = { flow: "signup", region: input.region };
|
|
61
|
-
if (input.responseMode !== undefined) {
|
|
62
|
-
if (input.responseMode !== "standard" && input.responseMode !== "detailed")
|
|
63
|
-
throw new Error("responseMode must be standard or detailed; raw is never served");
|
|
84
|
+
if (input.responseMode !== undefined)
|
|
64
85
|
body.response_mode = input.responseMode;
|
|
65
|
-
|
|
66
|
-
|
|
86
|
+
if (input.clientContext !== undefined)
|
|
87
|
+
body.client_context = input.clientContext;
|
|
88
|
+
if (input.authContext !== undefined)
|
|
89
|
+
body.auth_context = input.authContext;
|
|
90
|
+
return this.request("/v1/assessments", {
|
|
91
|
+
method: "POST", body: JSON.stringify(body), headers: { "Idempotency-Key": input.idempotencyKey },
|
|
92
|
+
});
|
|
67
93
|
}
|
|
68
94
|
async getAssessment(assessmentId, options = {}) {
|
|
69
|
-
if (
|
|
95
|
+
if (!ASSESSMENT_ID.test(assessmentId))
|
|
70
96
|
throw new Error("Invalid assessment ID");
|
|
71
|
-
|
|
72
|
-
throw new Error("responseMode must be standard or detailed");
|
|
97
|
+
this.validateMode(options.responseMode);
|
|
73
98
|
const suffix = options.responseMode ? `?response_mode=${options.responseMode}` : "";
|
|
74
99
|
return this.request(`/v1/assessments/${assessmentId}${suffix}`, { method: "GET" });
|
|
75
100
|
}
|
|
101
|
+
async getAssessmentWithHeaders(assessmentId, options = {}) {
|
|
102
|
+
if (!ASSESSMENT_ID.test(assessmentId))
|
|
103
|
+
throw new Error("Invalid assessment ID");
|
|
104
|
+
this.validateMode(options.responseMode);
|
|
105
|
+
const suffix = options.responseMode ? `?response_mode=${options.responseMode}` : "";
|
|
106
|
+
let retryAfter;
|
|
107
|
+
const assessment = await this.request(`/v1/assessments/${assessmentId}${suffix}`, {
|
|
108
|
+
method: "GET",
|
|
109
|
+
onResponse: (headers) => { retryAfter = parseRetryAfter(headers.get("Retry-After")); },
|
|
110
|
+
});
|
|
111
|
+
return { assessment, retryAfter };
|
|
112
|
+
}
|
|
113
|
+
async waitForAssessment(assessmentId, options = {}) {
|
|
114
|
+
const timeoutMs = options.timeoutMs ?? 30_000;
|
|
115
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0)
|
|
116
|
+
throw new Error("wait timeoutMs must be a positive integer");
|
|
117
|
+
const deadline = Date.now() + timeoutMs;
|
|
118
|
+
for (;;) {
|
|
119
|
+
const { assessment, retryAfter } = await this.getAssessmentWithHeaders(assessmentId, options);
|
|
120
|
+
if (assessment.status !== "pending")
|
|
121
|
+
return assessment;
|
|
122
|
+
const remaining = deadline - Date.now();
|
|
123
|
+
if (remaining <= 0)
|
|
124
|
+
throw new Error("Timed out waiting for assessment");
|
|
125
|
+
const delay = retryAfter === undefined ? 500 : retryAfter * 1000;
|
|
126
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(delay, remaining)));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
76
129
|
async queryAssessments(query) {
|
|
77
|
-
if (!query.account_id || (query.limit !== undefined && (!Number.isInteger(query.limit) || query.limit < 1 || query.limit > 50)))
|
|
130
|
+
if (!query.account_id || (query.limit !== undefined && (!Number.isInteger(query.limit) || query.limit < 1 || query.limit > 50))) {
|
|
78
131
|
throw new Error("Invalid assessment query");
|
|
132
|
+
}
|
|
79
133
|
if (query.responseMode !== undefined && query.response_mode !== undefined)
|
|
80
134
|
throw new Error("Supply only one response mode");
|
|
81
135
|
const mode = query.responseMode ?? query.response_mode;
|
|
82
|
-
|
|
83
|
-
throw new Error("responseMode must be standard or detailed; raw is never served");
|
|
136
|
+
this.validateMode(mode);
|
|
84
137
|
const body = { account_id: query.account_id };
|
|
85
138
|
if (query.limit !== undefined)
|
|
86
139
|
body.limit = query.limit;
|
|
@@ -90,37 +143,102 @@ export class NoResponse {
|
|
|
90
143
|
body.response_mode = mode;
|
|
91
144
|
return this.request("/v1/assessments/query", { method: "POST", body: JSON.stringify(body) });
|
|
92
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* Report what was later learned about an assessed account. confirmed_abuse, account_takeover and
|
|
148
|
+
* chargeback report abuse; false_positive and legitimate withdraw this tenant's earlier report;
|
|
149
|
+
* unknown is recorded only. A retry with the same idempotencyKey and body answers the same record.
|
|
150
|
+
*/
|
|
151
|
+
async recordOutcome(assessmentId, input) {
|
|
152
|
+
if (!ASSESSMENT_ID.test(assessmentId))
|
|
153
|
+
throw new Error("Invalid assessment ID");
|
|
154
|
+
if (!OUTCOMES.includes(input.outcome))
|
|
155
|
+
throw new Error(`outcome must be one of ${OUTCOMES.join(", ")}`);
|
|
156
|
+
if (!IDEMPOTENCY.test(input.idempotencyKey))
|
|
157
|
+
throw new Error("Valid 16-128 character idempotencyKey required");
|
|
158
|
+
if (input.observedAt !== undefined && !RFC3339.test(input.observedAt))
|
|
159
|
+
throw new Error("observedAt must be an RFC 3339 time");
|
|
160
|
+
if (input.externalRef !== undefined && !EXTERNAL_REF.test(input.externalRef))
|
|
161
|
+
throw new Error("externalRef must be 1-128 safe characters");
|
|
162
|
+
if (input.notes !== undefined && (input.notes.length < 1 || [...input.notes].length > 500))
|
|
163
|
+
throw new Error("notes must be 1-500 characters");
|
|
164
|
+
const body = { outcome: input.outcome };
|
|
165
|
+
if (input.observedAt !== undefined)
|
|
166
|
+
body.observed_at = input.observedAt;
|
|
167
|
+
if (input.externalRef !== undefined)
|
|
168
|
+
body.external_ref = input.externalRef;
|
|
169
|
+
if (input.notes !== undefined)
|
|
170
|
+
body.notes = input.notes;
|
|
171
|
+
return this.request(`/v1/assessments/${assessmentId}/outcomes`, {
|
|
172
|
+
method: "POST", body: JSON.stringify(body), headers: { "Idempotency-Key": input.idempotencyKey },
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
validateMode(mode) {
|
|
176
|
+
if (mode !== undefined && mode !== "standard" && mode !== "detailed")
|
|
177
|
+
throw new Error("responseMode must be standard or detailed; raw is never served");
|
|
178
|
+
}
|
|
93
179
|
async request(path, options) {
|
|
94
180
|
const transport = this.config.fetch ?? fetch;
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
...(options.body ? { "Content-Type": "application/json" } : {}),
|
|
101
|
-
...options.headers,
|
|
102
|
-
},
|
|
103
|
-
body: options.body,
|
|
104
|
-
signal: AbortSignal.timeout(this.config.timeoutMs ?? 5000),
|
|
105
|
-
});
|
|
106
|
-
if (!response.ok) {
|
|
107
|
-
let problem = {};
|
|
181
|
+
const maxRetries = this.config.maxRetries ?? 2;
|
|
182
|
+
const baseDelay = this.config.retryBaseDelayMs ?? 100;
|
|
183
|
+
let lastError;
|
|
184
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
185
|
+
let response;
|
|
108
186
|
try {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
...(
|
|
115
|
-
...
|
|
116
|
-
}
|
|
117
|
-
|
|
187
|
+
response = await transport(this.baseUrl + path, {
|
|
188
|
+
method: options.method,
|
|
189
|
+
headers: {
|
|
190
|
+
Authorization: `Bearer ${this.config.apiKey}`,
|
|
191
|
+
Accept: "application/json",
|
|
192
|
+
...(options.body ? { "Content-Type": "application/json" } : {}),
|
|
193
|
+
...options.headers,
|
|
194
|
+
},
|
|
195
|
+
body: options.body,
|
|
196
|
+
signal: AbortSignal.timeout(this.config.timeoutMs ?? 5000),
|
|
197
|
+
});
|
|
118
198
|
}
|
|
119
|
-
catch {
|
|
120
|
-
|
|
199
|
+
catch (error) {
|
|
200
|
+
lastError = error;
|
|
201
|
+
if (attempt >= maxRetries)
|
|
202
|
+
throw error;
|
|
203
|
+
await this.waitForRetry(attempt, undefined, baseDelay);
|
|
204
|
+
continue;
|
|
121
205
|
}
|
|
122
|
-
|
|
206
|
+
if (!response.ok) {
|
|
207
|
+
let problem = {};
|
|
208
|
+
try {
|
|
209
|
+
const parsed = await response.json();
|
|
210
|
+
if (parsed !== null && typeof parsed === "object") {
|
|
211
|
+
const body = parsed;
|
|
212
|
+
problem = {
|
|
213
|
+
...(typeof body.type === "string" ? { type: body.type } : {}),
|
|
214
|
+
...(typeof body.code === "string" ? { code: body.code } : {}),
|
|
215
|
+
...(typeof body.detail === "string" ? { detail: body.detail } : {}),
|
|
216
|
+
...(typeof body.request_id === "string" ? { request_id: body.request_id } : {}),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
catch { /* preserve the typed error for malformed bodies */ }
|
|
221
|
+
const retryAfter = parseRetryAfter(response.headers.get("Retry-After"));
|
|
222
|
+
const error = new NoResponseError(response.status, `NoResponse request failed (HTTP ${response.status})`, problem.type, problem.code, problem.detail, { requestId: problem.request_id ?? response.headers.get("X-Request-ID") ?? undefined, retryAfter });
|
|
223
|
+
lastError = error;
|
|
224
|
+
if (attempt < maxRetries && error.retryable) {
|
|
225
|
+
await this.waitForRetry(attempt, retryAfter, baseDelay);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
throw error;
|
|
229
|
+
}
|
|
230
|
+
options.onResponse?.(response.headers);
|
|
231
|
+
return response.json();
|
|
123
232
|
}
|
|
124
|
-
|
|
233
|
+
throw lastError instanceof Error ? lastError : new Error("NoResponse request failed");
|
|
234
|
+
}
|
|
235
|
+
async waitForRetry(attempt, retryAfter, baseDelay) {
|
|
236
|
+
const delay = retryAfter !== undefined ? retryAfter * 1000 : baseDelay * (2 ** Math.min(attempt, 6));
|
|
237
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
125
238
|
}
|
|
126
239
|
}
|
|
240
|
+
function parseRetryAfter(value) {
|
|
241
|
+
if (!value || !/^[1-9][0-9]{0,3}$/.test(value))
|
|
242
|
+
return undefined;
|
|
243
|
+
return Number(value);
|
|
244
|
+
}
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noresponse/node",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Server-only NoResponse signup and webhook SDK",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
9
|
"url": "git+https://github.com/noresponsehq/noresponse.git",
|
|
10
|
-
"directory": "sdks/typescript"
|
|
10
|
+
"directory": "apps/identity-intelligence/sdks/typescript"
|
|
11
11
|
},
|
|
12
12
|
"engines": {
|
|
13
13
|
"node": ">=20"
|