@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 CHANGED
@@ -24,7 +24,7 @@ if (assessment.status === "pending") {
24
24
  }
25
25
  ```
26
26
 
27
- Also provides `getAssessment(assessmentId, { responseMode })` and `queryAssessments({ account_id, limit, cursor, responseMode })`. `responseMode: "detailed"` (with the `detailed` key scope) adds `risk.details` and `signals[]`; `raw` is refused.
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" | "whatsapp_absent" | "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";
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
+ };
@@ -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
- /** standard or detailed; raw is never served. Derived from the generated request type. */
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
- constructor(status: number, message: string, type?: string | undefined, code?: string | undefined, detail?: string | undefined);
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
- requestId?: string;
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
- constructor(status, message, type, code, detail) {
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 (!/^[A-Za-z0-9._:-]{16,128}$/.test(input.idempotencyKey))
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.region !== undefined)
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
- return this.request("/v1/assessments", { method: "POST", body: JSON.stringify(body), headers: { "Idempotency-Key": input.idempotencyKey } });
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 (!/^asm_[0-9A-HJKMNP-TV-Z]{26}$/.test(assessmentId))
95
+ if (!ASSESSMENT_ID.test(assessmentId))
70
96
  throw new Error("Invalid assessment ID");
71
- if (options.responseMode !== undefined && options.responseMode !== "standard" && options.responseMode !== "detailed")
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
- if (mode !== undefined && mode !== "standard" && mode !== "detailed")
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 response = await transport(this.baseUrl + path, {
96
- method: options.method,
97
- headers: {
98
- Authorization: `Bearer ${this.config.apiKey}`,
99
- Accept: "application/json",
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
- const parsed = await response.json();
110
- if (parsed !== null && typeof parsed === "object") {
111
- const body = parsed;
112
- problem = {
113
- ...(typeof body.type === "string" ? { type: body.type } : {}),
114
- ...(typeof body.code === "string" ? { code: body.code } : {}),
115
- ...(typeof body.detail === "string" ? { detail: body.detail } : {}),
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
- // Preserve NoResponseError when the error body is empty or invalid JSON.
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
- throw new NoResponseError(response.status, `NoResponse request failed (HTTP ${response.status})`, problem.type, problem.code, problem.detail);
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
- return response.json();
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.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"