diff --git a/example/getEvent.mjs b/example/getEvent.mjs index 0500d26b..d679fdd2 100644 --- a/example/getEvent.mjs +++ b/example/getEvent.mjs @@ -32,7 +32,8 @@ try { const event = await client.getEvent(eventId, { ruleset_id: rulesetId }) console.log(JSON.stringify(event, null, 2)) - if (rulesetId && event.rule_action) { + // Edge events have no rule action. + if (rulesetId && event.source !== 'edge' && event.rule_action) { const { type, ruleset_id, rule_id, rule_expression } = event.rule_action console.log(`Rule action: ${type} (ruleset: ${ruleset_id}, rule: ${rule_id}, expression: ${rule_expression})`) diff --git a/readme.md b/readme.md index a1ee6888..9429b020 100644 --- a/readme.md +++ b/readme.md @@ -97,7 +97,10 @@ client.getEvent('').then((event) => { // Get an event with a ruleset evaluation client.getEvent('', { ruleset_id: '' }).then((event) => { - console.log(event.rule_action?.type) // 'allow' or 'block' + // Edge events (Automation Intelligence API) have no Identification or rule action. + if (event.source !== 'edge') { + console.log(event.rule_action?.type) // 'allow' or 'block' + } }) // Search for identification events @@ -151,12 +154,18 @@ Other `SdkError`s: #### Webhook types -When handling [Webhooks](https://dev.fingerprint.com/reference/posteventwebhook#/) coming from Fingerprint, you can cast the payload as the built-in `Event` type: +When handling [Webhooks](https://dev.fingerprint.com/reference/posteventwebhook#/) coming from Fingerprint, you can cast the payload as the built-in `Webhook` type: ```ts -import { Event } from '@fingerprint/node-sdk' +import { Webhook } from '@fingerprint/node-sdk' + +const webhook = eventWebhookBody as unknown as Webhook -const event = eventWebhookBody as unknown as Event +// Edge events (Automation Intelligence API) have no Identification. +// Bodies stored before `source` was added have no `source`; they are device events. +if (webhook.source !== 'edge') { + console.log(webhook.identification?.visitor_id) +} ``` #### Webhook signature validation diff --git a/resources/fingerprint-server-api.yaml b/resources/fingerprint-server-api.yaml index 6b178b67..7f7d4380 100644 --- a/resources/fingerprint-server-api.yaml +++ b/resources/fingerprint-server-api.yaml @@ -1190,6 +1190,11 @@ components: enum: - device - edge + EnvironmentId: + type: string + examples: + - ae_47abaca3db2c7c43 + description: Environment ID of the event. Url: type: string examples: @@ -1597,6 +1602,52 @@ components: description: > `true` if the request came from a device running a VPN, `false` otherwise. + EventEdge: + type: object + description: >- + An event generated by the Automation Intelligence API (`source` is + `edge`), containing IP and bot analysis for a request intercepted at the + edge. No client-side agent is involved, so Identification (`visitor_id`) + and Smart Signals derived from device telemetry are not available. + `ip_info` is always present, but is empty when no IP intelligence is + available for the request. + required: + - event_id + - timestamp + - source + - ip_info + properties: + event_id: + $ref: '#/components/schemas/EventId' + timestamp: + $ref: '#/components/schemas/Timestamp' + source: + $ref: '#/components/schemas/EventSource' + const: edge + linked_id: + $ref: '#/components/schemas/LinkedId' + environment_id: + $ref: '#/components/schemas/EnvironmentId' + tags: + $ref: '#/components/schemas/Tags' + url: + $ref: '#/components/schemas/Url' + bot_info: + $ref: '#/components/schemas/BotInfo' + ip_info: + $ref: '#/components/schemas/IPInfo' + proxy: + $ref: '#/components/schemas/Proxy' + proxy_confidence: + $ref: '#/components/schemas/ProxyConfidence' + proxy_details: + $ref: '#/components/schemas/ProxyDetails' + vpn: + $ref: '#/components/schemas/Vpn' + vpn_confidence: + $ref: '#/components/schemas/VpnConfidence' + vpn_methods: + $ref: '#/components/schemas/VpnMethods' ErrorCode: type: string enum: @@ -1706,11 +1757,6 @@ components: enum: - partially_completed - completed - EnvironmentId: - type: string - examples: - - ae_47abaca3db2c7c43 - description: Environment ID of the event. Suspect: type: boolean description: >- @@ -3130,18 +3176,21 @@ components: case. This field is in the beta phase and only available to select customers. If you are interested, please [contact our support team](https://fingerprint.com/support/). - Event: + EventDevice: type: object description: >- - Contains results from Fingerprint Identification and all active Smart - Signals. Some Smart Signals are only supported for certain device types, - these fields will be omitted for events not generated from the supported - devices. Consult the [Smart Signals + An event generated by the JS agent or a mobile SDK running on an + end-user device (`source` is `device`). Contains results from + Fingerprint Identification and all active Smart Signals. Some Smart + Signals are only supported for certain device types, these fields will + be omitted for events not generated from the supported devices. Consult + the [Smart Signals reference](https://docs.fingerprint.com/docs/smart-signals-reference) for more details. required: - event_id - timestamp + - source properties: event_id: $ref: '#/components/schemas/EventId' @@ -3157,6 +3206,7 @@ components: - browser source: $ref: '#/components/schemas/EventSource' + const: device x-platforms: - android - ios @@ -3485,6 +3535,29 @@ components: - browser - ios - android + Event: + type: object + description: > + An event, discriminated by its `source` property. Check `source` before + reading other properties, because each source populates a different set + of them: + + - `device` - an identification event generated by the JS agent or a + mobile SDK running on an end-user device. + + - `edge` - an event generated by the Automation Intelligence API + (`/edge` endpoint), analyzing a request intercepted at the edge. It + contains only IP and bot analysis, and never contains Identification + (`visitor_id`) or Smart Signals derived from device telemetry. + required: [] + oneOf: + - $ref: '#/components/schemas/EventDevice' + - $ref: '#/components/schemas/EventEdge' + discriminator: + propertyName: source + mapping: + device: '#/components/schemas/EventDevice' + edge: '#/components/schemas/EventEdge' EventUpdate: type: object required: [] diff --git a/src/generatedApiTypes.ts b/src/generatedApiTypes.ts index af03669a..61d35c97 100644 --- a/src/generatedApiTypes.ts +++ b/src/generatedApiTypes.ts @@ -155,6 +155,11 @@ export interface components { * @enum {string} */ EventSource: 'device' | 'edge' + /** + * @description Environment ID of the event. + * @example ae_47abaca3db2c7c43 + */ + EnvironmentId: string /** * @description Page URL from which the request was sent. * @example https://www.example.com/login @@ -419,6 +424,41 @@ export interface components { /** @description `true` if the request came from a device running a VPN, `false` otherwise. */ ml_prediction?: boolean } + /** @description An event generated by the Automation Intelligence API (`source` is `edge`), containing IP and bot analysis for a request intercepted at the edge. No client-side agent is involved, so Identification (`visitor_id`) and Smart Signals derived from device telemetry are not available. `ip_info` is always present, but is empty when no IP intelligence is available for the request. */ + EventEdge: { + /** @description Unique identifier of the user's request. The first portion of the event_id is a unix epoch milliseconds timestamp. */ + event_id: components['schemas']['EventId'] + /** @description Timestamp of the event with millisecond precision in Unix time. */ + timestamp: components['schemas']['Timestamp'] + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + source: 'edge' + /** @description A customer-provided ID that was sent with the request. */ + linked_id?: components['schemas']['LinkedId'] + /** @description Environment ID of the event. */ + environment_id?: components['schemas']['EnvironmentId'] + /** @description A customer-provided value or an object that was sent with the identification request or updated later. */ + tags?: components['schemas']['Tags'] + /** @description Page URL from which the request was sent. */ + url?: components['schemas']['Url'] + /** @description Extended bot information. */ + bot_info?: components['schemas']['BotInfo'] + /** @description Details about the request IP address. Has separate fields for v4 and v6 IP address versions. */ + ip_info: components['schemas']['IPInfo'] + /** @description IP address was used by a public proxy provider or belonged to a known recent residential proxy */ + proxy?: components['schemas']['Proxy'] + /** @description Confidence level of the proxy detection. If a proxy is not detected, confidence is "high". If it's detected, can be "low", "medium", or "high". */ + proxy_confidence?: components['schemas']['ProxyConfidence'] + /** @description Proxy detection details (present if `proxy` is `true`) */ + proxy_details?: components['schemas']['ProxyDetails'] + /** @description VPN or other anonymizing service has been used when sending the request. */ + vpn?: components['schemas']['Vpn'] + /** @description A confidence rating for the VPN detection result — "low", "medium", or "high". Depends on the combination of results returned from all VPN detection methods. */ + vpn_confidence?: components['schemas']['VpnConfidence'] + vpn_methods?: components['schemas']['VpnMethods'] + } /** * @description Error code: * * `request_cannot_be_parsed` - The query parameters or JSON payload contains some errors @@ -506,11 +546,6 @@ export interface components { * @enum {string} */ IncrementalIdentificationStatus: 'partially_completed' | 'completed' - /** - * @description Environment ID of the event. - * @example ae_47abaca3db2c7c43 - */ - EnvironmentId: string /** @description Field is `true` if you have previously set the `suspect` flag for this event using the [Server API Update event endpoint](https://docs.fingerprint.com/reference/server-api-update-event). */ Suspect: boolean Integration: { @@ -1412,18 +1447,17 @@ export interface components { */ ml_score?: number }[] - /** @description Contains results from Fingerprint Identification and all active Smart Signals. Some Smart Signals are only supported for certain device types, these fields will be omitted for events not generated from the supported devices. Consult the [Smart Signals reference](https://docs.fingerprint.com/docs/smart-signals-reference) for more details. */ - Event: { + /** @description An event generated by the JS agent or a mobile SDK running on an end-user device (`source` is `device`). Contains results from Fingerprint Identification and all active Smart Signals. Some Smart Signals are only supported for certain device types, these fields will be omitted for events not generated from the supported devices. Consult the [Smart Signals reference](https://docs.fingerprint.com/docs/smart-signals-reference) for more details. */ + EventDevice: { /** @description Unique identifier of the user's request. The first portion of the event_id is a unix epoch milliseconds timestamp. */ event_id: components['schemas']['EventId'] /** @description Timestamp of the event with millisecond precision in Unix time. */ timestamp: components['schemas']['Timestamp'] /** - * @description Identifies how the event was generated. - * - `device` - the event was generated by the JS agent or a mobile SDK running on an end-user device. - * - `edge` - the event was generated by the Automation Intelligence API (`/edge` endpoint), analyzing a request intercepted at the edge. + * @description discriminator enum property added by openapi-typescript + * @enum {string} */ - source?: components['schemas']['EventSource'] + source: 'device' /** * @description Only included for requests using incremental identification. * - `partially_completed` - Indicates this event corresponds to a 'minimal' request. Smart Signals, even if included in your plan, are not computed; hence, their values must be ignored. @@ -1621,6 +1655,12 @@ export interface components { /** @description Each label returns a prediction (true or false) for a specific use case (label field) based on a machine learning score. The machine learning score is determined by a model trained on customer data for that use case. This field is in the beta phase and only available to select customers. If you are interested, please [contact our support team](https://fingerprint.com/support/). */ labels?: components['schemas']['Labels'] } + /** + * @description An event, discriminated by its `source` property. Check `source` before reading other properties, because each source populates a different set of them: + * - `device` - an identification event generated by the JS agent or a mobile SDK running on an end-user device. + * - `edge` - an event generated by the Automation Intelligence API (`/edge` endpoint), analyzing a request intercepted at the edge. It contains only IP and bot analysis, and never contains Identification (`visitor_id`) or Smart Signals derived from device telemetry. + */ + Event: components['schemas']['EventDevice'] | components['schemas']['EventEdge'] EventUpdate: { /** * @description Linked ID value to assign to the existing event diff --git a/src/index.ts b/src/index.ts index 2c9f2954..52dec31f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ export { Region, SearchEventsFilter, SearchEventsResponse, + Webhook, } from './types' export * from './sealedResults' export * from './errors/unsealError' diff --git a/src/types.ts b/src/types.ts index 41d97723..6adc047a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -50,6 +50,18 @@ export type SearchEventsResponse = components['schemas']['EventSearch'] */ export type Event = components['schemas']['Event'] +/** + * Body of a webhook sent by Fingerprint. + * Same as {@link Event}, except `source` is optional on device events: bodies stored before `source` was added + * don't have it, and resent webhooks deliver the stored body as-is. Check `source === 'edge'`; anything else is a + * device event. + * + * More info: https://dev.fingerprint.com/reference/posteventwebhook + */ +export type Webhook = + | (Omit & { source?: components['schemas']['EventDevice']['source'] }) + | components['schemas']['EventEdge'] + export type GetEventOptions = paths['/events/{event_id}']['get']['parameters']['query'] export type EventUpdate = components['schemas']['EventUpdate'] diff --git a/tests/mocked-responses-tests/castWebhookEventTest.spec.ts b/tests/mocked-responses-tests/castWebhookEventTest.spec.ts index 51f786ff..94a74ccd 100644 --- a/tests/mocked-responses-tests/castWebhookEventTest.spec.ts +++ b/tests/mocked-responses-tests/castWebhookEventTest.spec.ts @@ -1,12 +1,27 @@ +import { createHmac } from 'crypto' import { describe, expect, it } from 'vitest' -import { Event } from '../../src' +import { Webhook, isValidWebhookSignature } from '../../src' import eventWebhookBody from './mocked-responses-data/webhook/webhook_event.json' describe('[Mocked body] Cast webhook event', () => { it('with sample request body', () => { - const event = eventWebhookBody as Event + const event = eventWebhookBody as Webhook // Assertion just to use the `event` variable. The goal of this test is to assume that Typescript won't throw an error here. expect(event).toBeTruthy() }) + + it('with a body that predates `source`', () => { + const oldBody: Record = { ...eventWebhookBody } + delete oldBody.source + const data = Buffer.from(JSON.stringify(oldBody)) + const secret = 'secret' + const header = `v1=${createHmac('sha256', secret).update(data).digest('hex')}` + + expect(isValidWebhookSignature({ header, data, secret })).toEqual(true) + + const event = JSON.parse(data.toString()) as Webhook + expect(event).not.toHaveProperty('source') + expect(event.event_id).toEqual(eventWebhookBody.event_id) + }) }) diff --git a/tests/mocked-responses-tests/mocked-responses-data/webhook/webhook_event.json b/tests/mocked-responses-tests/mocked-responses-data/webhook/webhook_event.json index 35567917..c50663be 100644 --- a/tests/mocked-responses-tests/mocked-responses-data/webhook/webhook_event.json +++ b/tests/mocked-responses-tests/mocked-responses-data/webhook/webhook_event.json @@ -3,9 +3,14 @@ "tags": {}, "timestamp": 1708102555327, "event_id": "1708102555327.NLOjmg", + "source": "device", + "incremental_identification_status": "completed", "url": "https://www.example.com/login?hope{this{works[!", "ip_address": "61.127.217.15", "user_agent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64) ....", + "device": "Other", + "os": "Windows", + "os_version": "7", "client_referrer": "https://example.com/blog/my-article", "browser_details": { "browser_name": "Chrome", @@ -26,14 +31,14 @@ "last_seen_at": 1708102555327 }, "supplementary_id_high_recall": { - "visitor_id": "3HNey93AkBW6CRbxV6xP", + "visitor_id": "0jnGMkPYXX37DqVa4ZIO3f_hr", "visitor_found": true, "confidence": { - "score": 0.97, - "version": "1.1" + "score": 0, + "version": "Not Supported" }, - "first_seen_at": 1708102555327, - "last_seen_at": 1708102555327 + "first_seen_at": 1778086556130, + "last_seen_at": 1778604975494 }, "proximity": { "id": "w1aTfd4MCvl", @@ -111,8 +116,10 @@ "last_seen_at": 1708102555327, "provider": "Massive" }, + "proxy_ml_score": 0.2, "vpn": false, "vpn_confidence": "high", + "vpn_ml_score": 0.2, "vpn_origin_timezone": "Europe/Berlin", "vpn_origin_country": "unknown", "vpn_methods": { @@ -120,10 +127,13 @@ "public_vpn": false, "auxiliary_mobile": false, "os_mismatch": false, - "relay": false + "relay": false, + "ml_prediction": false }, "incognito": false, "tampering": false, + "tampering_confidence": "high", + "tampering_ml_score": 0.3231, "tampering_details": { "anomaly_score": 0.1955, "anti_detect_browser": false @@ -143,6 +153,11 @@ "1_hour": 1, "24_hours": 1 }, + "distinct_linked_id": { + "5_minutes": 1, + "1_hour": 5, + "24_hours": 5 + }, "distinct_country": { "5_minutes": 1, "1_hour": 2, @@ -170,6 +185,11 @@ } }, "developer_tools": false, + "device_details": { + "device_manufacturer": "samsung", + "device_model": "SM-G991U", + "os_version": "13" + }, "mitm_attack": false, "sdk": { "platform": "js", @@ -187,8 +207,6 @@ }, "replayed": false, "high_activity_device": false, - "rare_device": false, - "rare_device_percentile_bucket": " { + it('requires `source`, since the API always returns it', () => { + // @ts-expect-error `source` is required on API events + const event: Event = { event_id: '1708102555327.NLOjmg', timestamp: 1708102555327 } + void event + }) + + it("narrows on `source === 'edge'`", () => { + const event = {} as Event + + if (event.source === 'edge') { + expectTypeOf(event.ip_info).not.toBeUndefined() + // @ts-expect-error edge events never have Identification + void event.identification + } else { + expectTypeOf(event.source).toEqualTypeOf<'device'>() + } + }) +}) + +describe('Webhook type', () => { + it('accepts a device body stored before `source` was added', () => { + const legacy: Webhook = { event_id: '1708102555327.NLOjmg', timestamp: 1708102555327 } + expectTypeOf(legacy).toExtend() + }) + + it("narrows to the edge variant on `source === 'edge'` and to the device variant otherwise", () => { + const webhook = {} as Webhook + + if (webhook.source === 'edge') { + expectTypeOf(webhook.ip_info).not.toBeUndefined() + // @ts-expect-error edge events never have Identification + void webhook.identification + } else { + expectTypeOf(webhook.source).toEqualTypeOf<'device' | undefined>() + expectTypeOf(webhook.identification?.visitor_id).toEqualTypeOf() + } + }) + + it("keeps the edge variant strict once `source` is 'edge'", () => { + // @ts-expect-error edge events require `ip_info` + const edge: Webhook = { event_id: 'x', timestamp: 1, source: 'edge' } + void edge + }) + + it('accepts every API event', () => { + expectTypeOf().toExtend() + }) +}) diff --git a/tests/unit-tests/sealedResults.spec.ts b/tests/unit-tests/sealedResults.spec.ts index 8ae8f0bd..71133b87 100644 --- a/tests/unit-tests/sealedResults.spec.ts +++ b/tests/unit-tests/sealedResults.spec.ts @@ -39,6 +39,13 @@ describe('Unseal event response', () => { expect(result).toMatchSnapshot() }) + it('unseals a payload that predates `source`', async () => { + const result = await unsealEventsResponse(sealedData, [{ key: validKey, algorithm: DecryptionAlgorithm.Aes256Gcm }]) + + expect(result).not.toHaveProperty('source') + expect(result.event_id).toBeTruthy() + }) + it('unseals sealed data using aes256gcm, passed as a string parameter', async () => { const result = await unsealEventsResponse(sealedData, [ {