@walkeros/server-transformer-fingerprint 4.6.0 → 4.7.0-next-1790187973605

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
@@ -6,11 +6,9 @@
6
6
 
7
7
  # @walkeros/server-transformer-fingerprint
8
8
 
9
- Server-side user identification for walkerOS without cookies. Hashes
10
- configurable request fields into a deterministic identifier and stores it on the
11
- event. No cookies, no PII stored: the same inputs always produce the same hash,
12
- which gives session continuity and cross-domain stitching without a client-side
13
- ID.
9
+ Cookieless, privacy-friendly visitor identification for walkerOS. Hashes the
10
+ anonymized IP, a reduced user agent and the site with a secret salt, rotates
11
+ daily, and stores the hash on the event. No cookies, no raw IP on the event.
14
12
 
15
13
  [Documentation](https://www.walkeros.io/docs/transformers/fingerprint) •
16
14
  [NPM Package](https://www.npmjs.com/package/@walkeros/server-transformer-fingerprint)
@@ -34,48 +32,20 @@ await startFlow({
34
32
  fingerprint: {
35
33
  code: transformerFingerprint,
36
34
  config: {
37
- settings: {
38
- fields: ['ingest.ip', 'ingest.userAgent'],
39
- output: 'user.hash',
40
- length: 16,
41
- },
35
+ settings: { salt: process.env.FINGERPRINT_SALT },
42
36
  },
43
37
  },
44
38
  },
45
39
  });
46
40
  ```
47
41
 
48
- The event then carries the hash at the configured `output` path:
49
-
50
- ```json
51
- { "name": "page view", "user": { "hash": "158f99cc06e33fd6" } }
52
- ```
53
-
54
- Fields resolve from `{ event, ingest }` using walkerOS mapping. Strings use dot
55
- notation, and function values compute dynamically. A missing field is treated as
56
- an empty string, and the transformer never throws.
57
-
58
- ## Daily rotation
59
-
60
- Without rotation the same IP and user agent produce the same hash indefinitely.
61
- Add a date field to reset it each day, which limits cross-day tracking while
62
- keeping session continuity within a day:
63
-
64
- ```typescript
65
- settings: {
66
- fields: [
67
- 'ingest.ip',
68
- 'ingest.userAgent',
69
- { fn: () => new Date().toISOString().slice(0, 10) },
70
- ],
71
- output: 'user.hash',
72
- length: 16,
73
- }
74
- ```
42
+ The source must extract `ip` and `userAgent` into `ingest` (its
43
+ `config.ingest`). The event then carries the hash at `user.hash`. Without a salt
44
+ the hash can be reversed to the IP, so the transformer logs a warning.
75
45
 
76
46
  ## Documentation
77
47
 
78
- Full configuration, IP anonymization, and examples live in the docs:
48
+ Full configuration, rotation, site separation, and examples live in the docs:
79
49
  **https://www.walkeros.io/docs/transformers/fingerprint**
80
50
 
81
51
  ## Contribute
package/dist/dev.d.mts CHANGED
@@ -8,11 +8,32 @@ import { Flow, Hint } from '@walkeros/core';
8
8
  * Mirrors: types.ts FingerprintSettings
9
9
  */
10
10
  declare const SettingsSchema: z.ZodObject<{
11
- fields: z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
11
+ salt: z.ZodOptional<z.ZodString>;
12
+ rotate: z.ZodOptional<z.ZodEnum<{
13
+ daily: "daily";
14
+ hourly: "hourly";
15
+ none: "none";
16
+ }>>;
17
+ ip: z.ZodOptional<z.ZodUnion<readonly [z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
12
18
  key: z.ZodOptional<z.ZodString>;
13
19
  value: z.ZodOptional<z.ZodUnknown>;
14
20
  fn: z.ZodOptional<z.ZodString>;
15
- }, z.core.$strip>]>>;
21
+ }, z.core.$strip>]>, z.ZodLiteral<false>]>>;
22
+ userAgent: z.ZodOptional<z.ZodUnion<readonly [z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
23
+ key: z.ZodOptional<z.ZodString>;
24
+ value: z.ZodOptional<z.ZodUnknown>;
25
+ fn: z.ZodOptional<z.ZodString>;
26
+ }, z.core.$strip>]>, z.ZodLiteral<false>]>>;
27
+ site: z.ZodOptional<z.ZodUnion<readonly [z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
28
+ key: z.ZodOptional<z.ZodString>;
29
+ value: z.ZodOptional<z.ZodUnknown>;
30
+ fn: z.ZodOptional<z.ZodString>;
31
+ }, z.core.$strip>]>, z.ZodLiteral<false>]>>;
32
+ fields: z.ZodOptional<z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
33
+ key: z.ZodOptional<z.ZodString>;
34
+ value: z.ZodOptional<z.ZodUnknown>;
35
+ fn: z.ZodOptional<z.ZodString>;
36
+ }, z.core.$strip>]>>>;
16
37
  output: z.ZodOptional<z.ZodString>;
17
38
  length: z.ZodOptional<z.ZodNumber>;
18
39
  }, z.core.$strip>;
package/dist/dev.d.ts CHANGED
@@ -8,11 +8,32 @@ import { Flow, Hint } from '@walkeros/core';
8
8
  * Mirrors: types.ts FingerprintSettings
9
9
  */
10
10
  declare const SettingsSchema: z.ZodObject<{
11
- fields: z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
11
+ salt: z.ZodOptional<z.ZodString>;
12
+ rotate: z.ZodOptional<z.ZodEnum<{
13
+ daily: "daily";
14
+ hourly: "hourly";
15
+ none: "none";
16
+ }>>;
17
+ ip: z.ZodOptional<z.ZodUnion<readonly [z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
12
18
  key: z.ZodOptional<z.ZodString>;
13
19
  value: z.ZodOptional<z.ZodUnknown>;
14
20
  fn: z.ZodOptional<z.ZodString>;
15
- }, z.core.$strip>]>>;
21
+ }, z.core.$strip>]>, z.ZodLiteral<false>]>>;
22
+ userAgent: z.ZodOptional<z.ZodUnion<readonly [z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
23
+ key: z.ZodOptional<z.ZodString>;
24
+ value: z.ZodOptional<z.ZodUnknown>;
25
+ fn: z.ZodOptional<z.ZodString>;
26
+ }, z.core.$strip>]>, z.ZodLiteral<false>]>>;
27
+ site: z.ZodOptional<z.ZodUnion<readonly [z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
28
+ key: z.ZodOptional<z.ZodString>;
29
+ value: z.ZodOptional<z.ZodUnknown>;
30
+ fn: z.ZodOptional<z.ZodString>;
31
+ }, z.core.$strip>]>, z.ZodLiteral<false>]>>;
32
+ fields: z.ZodOptional<z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
33
+ key: z.ZodOptional<z.ZodString>;
34
+ value: z.ZodOptional<z.ZodUnknown>;
35
+ fn: z.ZodOptional<z.ZodString>;
36
+ }, z.core.$strip>]>>>;
16
37
  output: z.ZodOptional<z.ZodString>;
17
38
  length: z.ZodOptional<z.ZodNumber>;
18
39
  }, z.core.$strip>;
package/dist/dev.js CHANGED
@@ -1 +1 @@
1
- "use strict";var e,t=Object.defineProperty,i=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,s=Object.prototype.hasOwnProperty,n=(e,i)=>{for(var r in i)t(e,r,{get:i[r],enumerable:!0})},a={};n(a,{examples:()=>c,hints:()=>v,schemas:()=>o}),module.exports=(e=a,((e,n,a,o)=>{if(n&&"object"==typeof n||"function"==typeof n)for(let p of r(n))s.call(e,p)||p===a||t(e,p,{get:()=>n[p],enumerable:!(o=i(n,p))||o.enumerable});return e})(t({},"__esModule",{value:!0}),e));var o={};n(o,{SettingsSchema:()=>d,settings:()=>l});var p=require("@walkeros/core/dev"),g=require("@walkeros/core/dev"),d=g.z.object({fields:g.z.array(g.z.union([g.z.string().describe('Dot-notation path: "ingest.ip", "event.data.userId"'),g.z.object({key:g.z.string().optional().describe("Source property path"),value:g.z.unknown().optional().describe("Static value or fallback"),fn:g.z.string().optional().describe("$code: function for value transformation")}).describe("Mapping value config for computed fields")])).describe("Fields to include in hash (order matters). Each resolved via getMappingValue with source { event, ingest }."),output:g.z.string().optional().describe('Dot-notation path where hash is stored on the event. Default: "user.hash"'),length:g.z.number().int().positive().optional().describe("Truncate hash to this length. Default: full 64-char SHA-256 hash")}).describe("Fingerprint transformer: generates deterministic user hashes from event fields"),l=(0,p.zodToSchema)(d),c={};n(c,{step:()=>u});var u={};n(u,{ipAnonymization:()=>h,missingFields:()=>f,serverFingerprint:()=>m});var m={title:"Server fingerprint",description:"Standard server fingerprint using ingest.ip and ingest.userAgent. Requires source config.ingest.",in:{name:"page view",data:{domain:"www.example.com",title:"Getting Started",id:"/docs/getting-started"},id:"ev-1700000600",trigger:"load",entity:"page",action:"view",timestamp:1700000600,source:{type:"express",platform:"server"}},out:[["return",{event:{name:"page view",data:{domain:"www.example.com",title:"Getting Started",id:"/docs/getting-started"},user:{hash:"158f99cc06e33fd6"},id:"ev-1700000600",trigger:"load",entity:"page",action:"view",timestamp:1700000600,source:{type:"express",platform:"server"}}}]]},f={public:!1,description:"Graceful handling when ingest is missing - fields resolve to empty strings, hash is still generated.",in:{name:"session start",data:{id:"s3ss10n"},id:"ev-1700000601",trigger:"load",entity:"session",action:"start",timestamp:1700000601,source:{type:"express",platform:"server"}},out:[["return",{event:{name:"session start",data:{id:"s3ss10n"},user:{hash:"e183220b699c10a8"},id:"ev-1700000601",trigger:"load",entity:"session",action:"start",timestamp:1700000601,source:{type:"express",platform:"server"}}}]]},h={title:"IP anonymization",description:'Privacy-preserving fingerprint using key+fn pattern: fn truncates IP to /24 subnet before hashing, so 10.0.42.* users share a hash. Config: fields: [{ key: "ingest.ip", fn: ip => ip.replace(/\\.\\d+$/, ".0") }, "ingest.userAgent"]',in:{name:"page view",data:{domain:"www.example.com",title:"Privacy Policy",id:"/privacy"},id:"ev-1700000602",trigger:"load",entity:"page",action:"view",timestamp:1700000602,source:{type:"express",platform:"server"}},out:[["return",{event:{name:"page view",data:{domain:"www.example.com",title:"Privacy Policy",id:"/privacy"},user:{hash:"44d9154b9a9b3792"},id:"ev-1700000602",trigger:"load",entity:"page",action:"view",timestamp:1700000602,source:{type:"express",platform:"server"}}}]]},v={"ingest-prerequisite":{text:'Fields starting with ingest.* require the server source to have config.ingest configured. config.ingest MUST use the map operator with direct request field paths (no req. prefix); a bare object like { ip: "req.ip" } is silently inert and leaves ingest empty. Without populated ingest, all ingest.* fields resolve to empty strings, the hash is still generated but not unique. Always pair this transformer with a source that extracts request metadata.',code:[{lang:"json",code:JSON.stringify({sources:{express:{package:"@walkeros/server-source-express",config:{settings:{port:8080},ingest:{map:{ip:{key:"ip"},userAgent:{key:"headers.user-agent"},origin:{key:"headers.origin"}}}}}},transformers:{fingerprint:{package:"@walkeros/server-transformer-fingerprint",config:{settings:{fields:["ingest.ip","ingest.userAgent"],output:"user.hash",length:16}}}}},null,2)}]},"fields-overview":{text:"Fields resolve from { event, ingest } via walkerOS mapping. Common patterns: ingest.ip (client IP), ingest.userAgent (browser UA), event.data.* (any event property). For time-based rotation use fn fields: daily rotation with toISOString().slice(0,10), monthly with getDate(). Order matters — same fields in different order produce different hashes. Use { key, fn } objects to transform before hashing (e.g., IP anonymization via the ipAnonymization step example)."}};//# sourceMappingURL=dev.js.map
1
+ "use strict";var e,t=Object.defineProperty,i=Object.getOwnPropertyDescriptor,s=Object.getOwnPropertyNames,n=Object.prototype.hasOwnProperty,r=(e,i)=>{for(var s in i)t(e,s,{get:i[s],enumerable:!0})},a={};r(a,{examples:()=>c,hints:()=>w,schemas:()=>o}),module.exports=(e=a,((e,r,a,o)=>{if(r&&"object"==typeof r||"function"==typeof r)for(let d of s(r))n.call(e,d)||d===a||t(e,d,{get:()=>r[d],enumerable:!(o=i(r,d))||o.enumerable});return e})(t({},"__esModule",{value:!0}),e));var o={};r(o,{SettingsSchema:()=>g,settings:()=>u});var d=require("@walkeros/core/dev"),l=require("@walkeros/core/dev"),h=l.z.union([l.z.string().describe('Dot-notation path: "ingest.ip", "event.data.userId"'),l.z.object({key:l.z.string().optional().describe("Source property path"),value:l.z.unknown().optional().describe("Static value or fallback"),fn:l.z.string().optional().describe("$code: function receiving { event, ingest } that returns the value. Runs only when no key is set.")}).describe("Mapping value config for computed fields")]),p=l.z.union([h,l.z.literal(!1).describe("Switch this input off")]),g=l.z.object({salt:l.z.string().optional().describe('Secret key for the hash (HMAC), e.g. "$env.FINGERPRINT_SALT". Without it the hash can be reversed to the IP; a warning is logged.'),rotate:l.z.enum(["daily","hourly","none"]).optional().describe('Rotation window in UTC: the same visitor gets a new hash each window. Default: "daily"'),ip:p.optional().describe('Client IP, anonymized to /24 (IPv4) or /48 (IPv6) before hashing. Default: "ingest.ip" when fields is not set'),userAgent:p.optional().describe('User agent, reduced to browser, major version and OS before hashing. Default: "ingest.userAgent" when fields is not set'),site:p.optional().describe('Site the visitor is on; a URL is reduced to its hostname. Default: "event.source.url" when fields is not set'),fields:l.z.array(h).optional().describe("Extra fields to include in hash (order matters). Each resolved via getMappingValue with source { event, ingest }. When set, named inputs are only used if set explicitly."),output:l.z.string().optional().describe('Dot-notation path where hash is stored on the event. Default: "user.hash"'),length:l.z.number().int().positive().optional().describe("Truncate hash to this length. Default: full 64-char SHA-256 hash")}).describe("Fingerprint transformer: a cookieless, privacy-friendly visitor hash"),u=(0,d.zodToSchema)(g),c={};r(c,{step:()=>f});var f={};r(f,{ipAnonymization:()=>y,missingFields:()=>m,serverFingerprint:()=>v});var v={title:"Server fingerprint",description:'Default server fingerprint: the anonymized ingest.ip, the reduced ingest.userAgent and the site, keyed with settings.salt and rotated daily. Config: { salt: "$env.FINGERPRINT_SALT", length: 16 }. Requires source config.ingest. The hash depends on the salt and the day.',in:{name:"page view",data:{domain:"www.example.com",title:"Getting Started",id:"/docs/getting-started"},id:"ev-1700000600",trigger:"load",entity:"page",action:"view",timestamp:1700000600,source:{type:"express",platform:"server"}},out:[["return",{event:{name:"page view",data:{domain:"www.example.com",title:"Getting Started",id:"/docs/getting-started"},user:{hash:"158f99cc06e33fd6"},id:"ev-1700000600",trigger:"load",entity:"page",action:"view",timestamp:1700000600,source:{type:"express",platform:"server"}}}]]},m={public:!1,description:"Graceful handling when ingest is missing: each empty input logs one warning, and the hash is still generated.",in:{name:"session start",data:{id:"s3ss10n"},id:"ev-1700000601",trigger:"load",entity:"session",action:"start",timestamp:1700000601,source:{type:"express",platform:"server"}},out:[["return",{event:{name:"session start",data:{id:"s3ss10n"},user:{hash:"e183220b699c10a8"},id:"ev-1700000601",trigger:"load",entity:"session",action:"start",timestamp:1700000601,source:{type:"express",platform:"server"}}}]]},y={title:"IP anonymization",description:'The ip input anonymizes the IP before hashing (IPv4 /24, IPv6 /48), so visitors from the same 10.0.42.* subnet with the same browser, major version and OS share a hash within a day. No custom fn needed. Config: { salt: "$env.FINGERPRINT_SALT", length: 16 }',in:{name:"page view",data:{domain:"www.example.com",title:"Privacy Policy",id:"/privacy"},id:"ev-1700000602",trigger:"load",entity:"page",action:"view",timestamp:1700000602,source:{type:"express",platform:"server"}},out:[["return",{event:{name:"page view",data:{domain:"www.example.com",title:"Privacy Policy",id:"/privacy"},user:{hash:"44d9154b9a9b3792"},id:"ev-1700000602",trigger:"load",entity:"page",action:"view",timestamp:1700000602,source:{type:"express",platform:"server"}}}]]},w={"ingest-prerequisite":{text:'The default inputs ip and userAgent read ingest.ip and ingest.userAgent, so the server source must have config.ingest configured. config.ingest MUST use the map operator with direct request field paths (no req. prefix); a bare object like { ip: "req.ip" } is silently inert and leaves ingest empty. An input that resolves empty logs one warning ("Fingerprint input ... resolved empty") and the hash is still generated, but less distinct. Behind a load balancer the request ip is the balancer; read the client from headers.x-forwarded-for instead.',code:[{lang:"json",code:JSON.stringify({sources:{express:{package:"@walkeros/server-source-express",next:"fingerprint",config:{settings:{port:8080},ingest:{map:{ip:{key:"ip"},userAgent:{key:"headers.user-agent"}}}}}},transformers:{fingerprint:{package:"@walkeros/server-transformer-fingerprint",config:{settings:{salt:"$env.FINGERPRINT_SALT"}}}}},null,2)}]},"salt-and-rotation":{text:'The salt keys the hash (HMAC), so it cannot be recomputed or reversed to the IP without it. Pass it via $env so it stays out of the flow file, and use one salt per deployment. Without a salt the transformer runs but logs a warning at startup. Rotation is built in (daily by default, UTC windows): do not add a date field for it. For another window, set rotate: "none" and add the window as a field. Setting fields switches the default inputs off, so name them too: { rotate: "none", ip: "ingest.ip", userAgent: "ingest.userAgent", site: "event.source.url", fields: [{ fn: () => new Date().toISOString().slice(0, 7) }] } rotates monthly. rotate: "none" without such a field keeps one hash per visitor indefinitely, which is a privacy decision, not a default.'},"site-sources":{text:'site defaults to the hostname of event.source.url, which the walkerOS browser source sets on every event. Events without it (GA4 hits decoded by transformer-ga4, server-side events) need site pointed elsewhere: an ingest path such as ingest.property (a path parameter) or ingest.origin (map it in config.ingest first, e.g. origin: { key: "headers.origin" }), or a static { value: "example.com" }. site keeps one visitor apart across sites when one collector with one salt serves several of them; a single-site deployment with its own salt is already separated by the salt.'},"extra-fields":{text:"fields adds values to the hash as they resolve from { event, ingest }, in order. When fields is set, the named inputs ip, userAgent and site are only used if set explicitly, so an existing fields config keeps its input set. To transform a value before hashing, use fn alone: it receives { event, ingest } and returns the value. A fn next to a key never runs. The IP never needs a custom fn: the ip input anonymizes it (IPv4 /24, IPv6 /48)."}};//# sourceMappingURL=dev.js.map
package/dist/dev.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/dev.ts","../src/schemas/index.ts","../src/schemas/settings.ts","../src/examples/index.ts","../src/examples/step.ts","../src/hints.ts"],"sourcesContent":["export * as schemas from './schemas';\nexport * as examples from './examples';\nexport { hints } from './hints';\n","import { zodToSchema } from '@walkeros/core/dev';\nimport { SettingsSchema } from './settings';\n\nexport { SettingsSchema, type Settings } from './settings';\nexport const settings = zodToSchema(SettingsSchema);\n","import { z } from '@walkeros/core/dev';\n\n/**\n * Fingerprint transformer settings schema.\n *\n * Mirrors: types.ts FingerprintSettings\n */\nexport const SettingsSchema = z\n .object({\n fields: z\n .array(\n z.union([\n z\n .string()\n .describe('Dot-notation path: \"ingest.ip\", \"event.data.userId\"'),\n z\n .object({\n key: z.string().optional().describe('Source property path'),\n value: z\n .unknown()\n .optional()\n .describe('Static value or fallback'),\n fn: z\n .string()\n .optional()\n .describe('$code: function for value transformation'),\n })\n .describe('Mapping value config for computed fields'),\n ]),\n )\n .describe(\n 'Fields to include in hash (order matters). Each resolved via getMappingValue with source { event, ingest }.',\n ),\n output: z\n .string()\n .optional()\n .describe(\n 'Dot-notation path where hash is stored on the event. Default: \"user.hash\"',\n ),\n length: z\n .number()\n .int()\n .positive()\n .optional()\n .describe(\n 'Truncate hash to this length. Default: full 64-char SHA-256 hash',\n ),\n })\n .describe(\n 'Fingerprint transformer: generates deterministic user hashes from event fields',\n );\n\nexport type Settings = z.infer<typeof SettingsSchema>;\n","export * as step from './step';\n","import type { Flow } from '@walkeros/core';\n\nexport const serverFingerprint: Flow.StepExample = {\n title: 'Server fingerprint',\n description:\n 'Standard server fingerprint using ingest.ip and ingest.userAgent. Requires source config.ingest.',\n in: {\n name: 'page view',\n data: {\n domain: 'www.example.com',\n title: 'Getting Started',\n id: '/docs/getting-started',\n },\n id: 'ev-1700000600',\n trigger: 'load',\n entity: 'page',\n action: 'view',\n timestamp: 1700000600,\n source: { type: 'express', platform: 'server' },\n },\n out: [\n [\n 'return',\n {\n event: {\n name: 'page view',\n data: {\n domain: 'www.example.com',\n title: 'Getting Started',\n id: '/docs/getting-started',\n },\n user: { hash: '158f99cc06e33fd6' },\n id: 'ev-1700000600',\n trigger: 'load',\n entity: 'page',\n action: 'view',\n timestamp: 1700000600,\n source: { type: 'express', platform: 'server' },\n },\n },\n ],\n ],\n};\n\nexport const missingFields: Flow.StepExample = {\n public: false,\n description:\n 'Graceful handling when ingest is missing - fields resolve to empty strings, hash is still generated.',\n in: {\n name: 'session start',\n data: { id: 's3ss10n' },\n id: 'ev-1700000601',\n trigger: 'load',\n entity: 'session',\n action: 'start',\n timestamp: 1700000601,\n source: { type: 'express', platform: 'server' },\n },\n out: [\n [\n 'return',\n {\n event: {\n name: 'session start',\n data: { id: 's3ss10n' },\n user: { hash: 'e183220b699c10a8' },\n id: 'ev-1700000601',\n trigger: 'load',\n entity: 'session',\n action: 'start',\n timestamp: 1700000601,\n source: { type: 'express', platform: 'server' },\n },\n },\n ],\n ],\n};\n\nexport const ipAnonymization: Flow.StepExample = {\n title: 'IP anonymization',\n description:\n 'Privacy-preserving fingerprint using key+fn pattern: ' +\n 'fn truncates IP to /24 subnet before hashing, so 10.0.42.* users share a hash. ' +\n 'Config: fields: [{ key: \"ingest.ip\", fn: ip => ip.replace(/\\\\.\\\\d+$/, \".0\") }, \"ingest.userAgent\"]',\n in: {\n name: 'page view',\n data: {\n domain: 'www.example.com',\n title: 'Privacy Policy',\n id: '/privacy',\n },\n id: 'ev-1700000602',\n trigger: 'load',\n entity: 'page',\n action: 'view',\n timestamp: 1700000602,\n source: { type: 'express', platform: 'server' },\n },\n out: [\n [\n 'return',\n {\n event: {\n name: 'page view',\n data: {\n domain: 'www.example.com',\n title: 'Privacy Policy',\n id: '/privacy',\n },\n user: { hash: '44d9154b9a9b3792' },\n id: 'ev-1700000602',\n trigger: 'load',\n entity: 'page',\n action: 'view',\n timestamp: 1700000602,\n source: { type: 'express', platform: 'server' },\n },\n },\n ],\n ],\n};\n","import type { Hint } from '@walkeros/core';\n\nexport const hints: Hint.Hints = {\n 'ingest-prerequisite': {\n text: 'Fields starting with ingest.* require the server source to have config.ingest configured. config.ingest MUST use the map operator with direct request field paths (no req. prefix); a bare object like { ip: \"req.ip\" } is silently inert and leaves ingest empty. Without populated ingest, all ingest.* fields resolve to empty strings, the hash is still generated but not unique. Always pair this transformer with a source that extracts request metadata.',\n code: [\n {\n lang: 'json',\n code: JSON.stringify(\n {\n sources: {\n express: {\n package: '@walkeros/server-source-express',\n config: {\n settings: { port: 8080 },\n ingest: {\n map: {\n ip: { key: 'ip' },\n userAgent: { key: 'headers.user-agent' },\n origin: { key: 'headers.origin' },\n },\n },\n },\n },\n },\n transformers: {\n fingerprint: {\n package: '@walkeros/server-transformer-fingerprint',\n config: {\n settings: {\n fields: ['ingest.ip', 'ingest.userAgent'],\n output: 'user.hash',\n length: 16,\n },\n },\n },\n },\n },\n null,\n 2,\n ),\n },\n ],\n },\n 'fields-overview': {\n text: 'Fields resolve from { event, ingest } via walkerOS mapping. Common patterns: ingest.ip (client IP), ingest.userAgent (browser UA), event.data.* (any event property). For time-based rotation use fn fields: daily rotation with toISOString().slice(0,10), monthly with getDate(). Order matters — same fields in different order produce different hashes. Use { key, fn } objects to transform before hashing (e.g., IP anonymization via the ipAnonymization step example).',\n },\n};\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAAAA,cAA4B;;;ACA5B,iBAAkB;AAOX,IAAM,iBAAiB,aAC3B,OAAO;AAAA,EACN,QAAQ,aACL;AAAA,IACC,aAAE,MAAM;AAAA,MACN,aACG,OAAO,EACP,SAAS,qDAAqD;AAAA,MACjE,aACG,OAAO;AAAA,QACN,KAAK,aAAE,OAAO,EAAE,SAAS,EAAE,SAAS,sBAAsB;AAAA,QAC1D,OAAO,aACJ,QAAQ,EACR,SAAS,EACT,SAAS,0BAA0B;AAAA,QACtC,IAAI,aACD,OAAO,EACP,SAAS,EACT,SAAS,0CAA0C;AAAA,MACxD,CAAC,EACA,SAAS,0CAA0C;AAAA,IACxD,CAAC;AAAA,EACH,EACC;AAAA,IACC;AAAA,EACF;AAAA,EACF,QAAQ,aACL,OAAO,EACP,SAAS,EACT;AAAA,IACC;AAAA,EACF;AAAA,EACF,QAAQ,aACL,OAAO,EACP,IAAI,EACJ,SAAS,EACT,SAAS,EACT;AAAA,IACC;AAAA,EACF;AACJ,CAAC,EACA;AAAA,EACC;AACF;;;AD9CK,IAAM,eAAW,yBAAY,cAAc;;;AEJlD;AAAA;AAAA;AAAA;;;ACAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAEO,IAAM,oBAAsC;AAAA,EACjD,OAAO;AAAA,EACP,aACE;AAAA,EACF,IAAI;AAAA,IACF,MAAM;AAAA,IACN,MAAM;AAAA,MACJ,QAAQ;AAAA,MACR,OAAO;AAAA,MACP,IAAI;AAAA,IACN;AAAA,IACA,IAAI;AAAA,IACJ,SAAS;AAAA,IACT,QAAQ;AAAA,IACR,QAAQ;AAAA,IACR,WAAW;AAAA,IACX,QAAQ,EAAE,MAAM,WAAW,UAAU,SAAS;AAAA,EAChD;AAAA,EACA,KAAK;AAAA,IACH;AAAA,MACE;AAAA,MACA;AAAA,QACE,OAAO;AAAA,UACL,MAAM;AAAA,UACN,MAAM;AAAA,YACJ,QAAQ;AAAA,YACR,OAAO;AAAA,YACP,IAAI;AAAA,UACN;AAAA,UACA,MAAM,EAAE,MAAM,mBAAmB;AAAA,UACjC,IAAI;AAAA,UACJ,SAAS;AAAA,UACT,QAAQ;AAAA,UACR,QAAQ;AAAA,UACR,WAAW;AAAA,UACX,QAAQ,EAAE,MAAM,WAAW,UAAU,SAAS;AAAA,QAChD;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACF;AAEO,IAAM,gBAAkC;AAAA,EAC7C,QAAQ;AAAA,EACR,aACE;AAAA,EACF,IAAI;AAAA,IACF,MAAM;AAAA,IACN,MAAM,EAAE,IAAI,UAAU;AAAA,IACtB,IAAI;AAAA,IACJ,SAAS;AAAA,IACT,QAAQ;AAAA,IACR,QAAQ;AAAA,IACR,WAAW;AAAA,IACX,QAAQ,EAAE,MAAM,WAAW,UAAU,SAAS;AAAA,EAChD;AAAA,EACA,KAAK;AAAA,IACH;AAAA,MACE;AAAA,MACA;AAAA,QACE,OAAO;AAAA,UACL,MAAM;AAAA,UACN,MAAM,EAAE,IAAI,UAAU;AAAA,UACtB,MAAM,EAAE,MAAM,mBAAmB;AAAA,UACjC,IAAI;AAAA,UACJ,SAAS;AAAA,UACT,QAAQ;AAAA,UACR,QAAQ;AAAA,UACR,WAAW;AAAA,UACX,QAAQ,EAAE,MAAM,WAAW,UAAU,SAAS;AAAA,QAChD;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACF;AAEO,IAAM,kBAAoC;AAAA,EAC/C,OAAO;AAAA,EACP,aACE;AAAA,EAGF,IAAI;AAAA,IACF,MAAM;AAAA,IACN,MAAM;AAAA,MACJ,QAAQ;AAAA,MACR,OAAO;AAAA,MACP,IAAI;AAAA,IACN;AAAA,IACA,IAAI;AAAA,IACJ,SAAS;AAAA,IACT,QAAQ;AAAA,IACR,QAAQ;AAAA,IACR,WAAW;AAAA,IACX,QAAQ,EAAE,MAAM,WAAW,UAAU,SAAS;AAAA,EAChD;AAAA,EACA,KAAK;AAAA,IACH;AAAA,MACE;AAAA,MACA;AAAA,QACE,OAAO;AAAA,UACL,MAAM;AAAA,UACN,MAAM;AAAA,YACJ,QAAQ;AAAA,YACR,OAAO;AAAA,YACP,IAAI;AAAA,UACN;AAAA,UACA,MAAM,EAAE,MAAM,mBAAmB;AAAA,UACjC,IAAI;AAAA,UACJ,SAAS;AAAA,UACT,QAAQ;AAAA,UACR,QAAQ;AAAA,UACR,WAAW;AAAA,UACX,QAAQ,EAAE,MAAM,WAAW,UAAU,SAAS;AAAA,QAChD;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACF;;;ACtHO,IAAM,QAAoB;AAAA,EAC/B,uBAAuB;AAAA,IACrB,MAAM;AAAA,IACN,MAAM;AAAA,MACJ;AAAA,QACE,MAAM;AAAA,QACN,MAAM,KAAK;AAAA,UACT;AAAA,YACE,SAAS;AAAA,cACP,SAAS;AAAA,gBACP,SAAS;AAAA,gBACT,QAAQ;AAAA,kBACN,UAAU,EAAE,MAAM,KAAK;AAAA,kBACvB,QAAQ;AAAA,oBACN,KAAK;AAAA,sBACH,IAAI,EAAE,KAAK,KAAK;AAAA,sBAChB,WAAW,EAAE,KAAK,qBAAqB;AAAA,sBACvC,QAAQ,EAAE,KAAK,iBAAiB;AAAA,oBAClC;AAAA,kBACF;AAAA,gBACF;AAAA,cACF;AAAA,YACF;AAAA,YACA,cAAc;AAAA,cACZ,aAAa;AAAA,gBACX,SAAS;AAAA,gBACT,QAAQ;AAAA,kBACN,UAAU;AAAA,oBACR,QAAQ,CAAC,aAAa,kBAAkB;AAAA,oBACxC,QAAQ;AAAA,oBACR,QAAQ;AAAA,kBACV;AAAA,gBACF;AAAA,cACF;AAAA,YACF;AAAA,UACF;AAAA,UACA;AAAA,UACA;AAAA,QACF;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAAA,EACA,mBAAmB;AAAA,IACjB,MAAM;AAAA,EACR;AACF;","names":["import_dev"]}
1
+ {"version":3,"sources":["../src/dev.ts","../src/schemas/index.ts","../src/schemas/settings.ts","../src/examples/index.ts","../src/examples/step.ts","../src/hints.ts"],"sourcesContent":["export * as schemas from './schemas';\nexport * as examples from './examples';\nexport { hints } from './hints';\n","import { zodToSchema } from '@walkeros/core/dev';\nimport { SettingsSchema } from './settings';\n\nexport { SettingsSchema, type Settings } from './settings';\nexport const settings = zodToSchema(SettingsSchema);\n","import { z } from '@walkeros/core/dev';\n\nconst FieldSchema = z.union([\n z.string().describe('Dot-notation path: \"ingest.ip\", \"event.data.userId\"'),\n z\n .object({\n key: z.string().optional().describe('Source property path'),\n value: z.unknown().optional().describe('Static value or fallback'),\n fn: z\n .string()\n .optional()\n .describe(\n '$code: function receiving { event, ingest } that returns the value. Runs only when no key is set.',\n ),\n })\n .describe('Mapping value config for computed fields'),\n]);\n\nconst InputSchema = z.union([\n FieldSchema,\n z.literal(false).describe('Switch this input off'),\n]);\n\n/**\n * Fingerprint transformer settings schema.\n *\n * Mirrors: types.ts FingerprintSettings\n */\nexport const SettingsSchema = z\n .object({\n salt: z\n .string()\n .optional()\n .describe(\n 'Secret key for the hash (HMAC), e.g. \"$env.FINGERPRINT_SALT\". Without it the hash can be reversed to the IP; a warning is logged.',\n ),\n rotate: z\n .enum(['daily', 'hourly', 'none'])\n .optional()\n .describe(\n 'Rotation window in UTC: the same visitor gets a new hash each window. Default: \"daily\"',\n ),\n ip: InputSchema.optional().describe(\n 'Client IP, anonymized to /24 (IPv4) or /48 (IPv6) before hashing. Default: \"ingest.ip\" when fields is not set',\n ),\n userAgent: InputSchema.optional().describe(\n 'User agent, reduced to browser, major version and OS before hashing. Default: \"ingest.userAgent\" when fields is not set',\n ),\n site: InputSchema.optional().describe(\n 'Site the visitor is on; a URL is reduced to its hostname. Default: \"event.source.url\" when fields is not set',\n ),\n fields: z\n .array(FieldSchema)\n .optional()\n .describe(\n 'Extra fields to include in hash (order matters). Each resolved via getMappingValue with source { event, ingest }. When set, named inputs are only used if set explicitly.',\n ),\n output: z\n .string()\n .optional()\n .describe(\n 'Dot-notation path where hash is stored on the event. Default: \"user.hash\"',\n ),\n length: z\n .number()\n .int()\n .positive()\n .optional()\n .describe(\n 'Truncate hash to this length. Default: full 64-char SHA-256 hash',\n ),\n })\n .describe(\n 'Fingerprint transformer: a cookieless, privacy-friendly visitor hash',\n );\n\nexport type Settings = z.infer<typeof SettingsSchema>;\n","export * as step from './step';\n","import type { Flow } from '@walkeros/core';\n\nexport const serverFingerprint: Flow.StepExample = {\n title: 'Server fingerprint',\n description:\n 'Default server fingerprint: the anonymized ingest.ip, the reduced ingest.userAgent and the site, keyed with settings.salt and rotated daily. ' +\n 'Config: { salt: \"$env.FINGERPRINT_SALT\", length: 16 }. Requires source config.ingest. The hash depends on the salt and the day.',\n in: {\n name: 'page view',\n data: {\n domain: 'www.example.com',\n title: 'Getting Started',\n id: '/docs/getting-started',\n },\n id: 'ev-1700000600',\n trigger: 'load',\n entity: 'page',\n action: 'view',\n timestamp: 1700000600,\n source: { type: 'express', platform: 'server' },\n },\n out: [\n [\n 'return',\n {\n event: {\n name: 'page view',\n data: {\n domain: 'www.example.com',\n title: 'Getting Started',\n id: '/docs/getting-started',\n },\n user: { hash: '158f99cc06e33fd6' },\n id: 'ev-1700000600',\n trigger: 'load',\n entity: 'page',\n action: 'view',\n timestamp: 1700000600,\n source: { type: 'express', platform: 'server' },\n },\n },\n ],\n ],\n};\n\nexport const missingFields: Flow.StepExample = {\n public: false,\n description:\n 'Graceful handling when ingest is missing: each empty input logs one warning, and the hash is still generated.',\n in: {\n name: 'session start',\n data: { id: 's3ss10n' },\n id: 'ev-1700000601',\n trigger: 'load',\n entity: 'session',\n action: 'start',\n timestamp: 1700000601,\n source: { type: 'express', platform: 'server' },\n },\n out: [\n [\n 'return',\n {\n event: {\n name: 'session start',\n data: { id: 's3ss10n' },\n user: { hash: 'e183220b699c10a8' },\n id: 'ev-1700000601',\n trigger: 'load',\n entity: 'session',\n action: 'start',\n timestamp: 1700000601,\n source: { type: 'express', platform: 'server' },\n },\n },\n ],\n ],\n};\n\nexport const ipAnonymization: Flow.StepExample = {\n title: 'IP anonymization',\n description:\n 'The ip input anonymizes the IP before hashing (IPv4 /24, IPv6 /48), so visitors from the same 10.0.42.* subnet ' +\n 'with the same browser, major version and OS share a hash within a day. No custom fn needed. Config: { salt: \"$env.FINGERPRINT_SALT\", length: 16 }',\n in: {\n name: 'page view',\n data: {\n domain: 'www.example.com',\n title: 'Privacy Policy',\n id: '/privacy',\n },\n id: 'ev-1700000602',\n trigger: 'load',\n entity: 'page',\n action: 'view',\n timestamp: 1700000602,\n source: { type: 'express', platform: 'server' },\n },\n out: [\n [\n 'return',\n {\n event: {\n name: 'page view',\n data: {\n domain: 'www.example.com',\n title: 'Privacy Policy',\n id: '/privacy',\n },\n user: { hash: '44d9154b9a9b3792' },\n id: 'ev-1700000602',\n trigger: 'load',\n entity: 'page',\n action: 'view',\n timestamp: 1700000602,\n source: { type: 'express', platform: 'server' },\n },\n },\n ],\n ],\n};\n","import type { Hint } from '@walkeros/core';\n\nexport const hints: Hint.Hints = {\n 'ingest-prerequisite': {\n text: 'The default inputs ip and userAgent read ingest.ip and ingest.userAgent, so the server source must have config.ingest configured. config.ingest MUST use the map operator with direct request field paths (no req. prefix); a bare object like { ip: \"req.ip\" } is silently inert and leaves ingest empty. An input that resolves empty logs one warning (\"Fingerprint input ... resolved empty\") and the hash is still generated, but less distinct. Behind a load balancer the request ip is the balancer; read the client from headers.x-forwarded-for instead.',\n code: [\n {\n lang: 'json',\n code: JSON.stringify(\n {\n sources: {\n express: {\n package: '@walkeros/server-source-express',\n next: 'fingerprint',\n config: {\n settings: { port: 8080 },\n ingest: {\n map: {\n ip: { key: 'ip' },\n userAgent: { key: 'headers.user-agent' },\n },\n },\n },\n },\n },\n transformers: {\n fingerprint: {\n package: '@walkeros/server-transformer-fingerprint',\n config: {\n settings: { salt: '$env.FINGERPRINT_SALT' },\n },\n },\n },\n },\n null,\n 2,\n ),\n },\n ],\n },\n 'salt-and-rotation': {\n text: 'The salt keys the hash (HMAC), so it cannot be recomputed or reversed to the IP without it. Pass it via $env so it stays out of the flow file, and use one salt per deployment. Without a salt the transformer runs but logs a warning at startup. Rotation is built in (daily by default, UTC windows): do not add a date field for it. For another window, set rotate: \"none\" and add the window as a field. Setting fields switches the default inputs off, so name them too: { rotate: \"none\", ip: \"ingest.ip\", userAgent: \"ingest.userAgent\", site: \"event.source.url\", fields: [{ fn: () => new Date().toISOString().slice(0, 7) }] } rotates monthly. rotate: \"none\" without such a field keeps one hash per visitor indefinitely, which is a privacy decision, not a default.',\n },\n 'site-sources': {\n text: 'site defaults to the hostname of event.source.url, which the walkerOS browser source sets on every event. Events without it (GA4 hits decoded by transformer-ga4, server-side events) need site pointed elsewhere: an ingest path such as ingest.property (a path parameter) or ingest.origin (map it in config.ingest first, e.g. origin: { key: \"headers.origin\" }), or a static { value: \"example.com\" }. site keeps one visitor apart across sites when one collector with one salt serves several of them; a single-site deployment with its own salt is already separated by the salt.',\n },\n 'extra-fields': {\n text: 'fields adds values to the hash as they resolve from { event, ingest }, in order. When fields is set, the named inputs ip, userAgent and site are only used if set explicitly, so an existing fields config keeps its input set. To transform a value before hashing, use fn alone: it receives { event, ingest } and returns the value. A fn next to a key never runs. The IP never needs a custom fn: the ip input anonymizes it (IPv4 /24, IPv6 /48).',\n },\n};\n"],"mappings":"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","names":["import_dev"]}
package/dist/dev.mjs CHANGED
@@ -1 +1 @@
1
- var e=Object.defineProperty,t=(t,i)=>{for(var r in i)e(t,r,{get:i[r],enumerable:!0})},i={};t(i,{SettingsSchema:()=>n,settings:()=>a});import{zodToSchema as r}from"@walkeros/core/dev";import{z as s}from"@walkeros/core/dev";var n=s.object({fields:s.array(s.union([s.string().describe('Dot-notation path: "ingest.ip", "event.data.userId"'),s.object({key:s.string().optional().describe("Source property path"),value:s.unknown().optional().describe("Static value or fallback"),fn:s.string().optional().describe("$code: function for value transformation")}).describe("Mapping value config for computed fields")])).describe("Fields to include in hash (order matters). Each resolved via getMappingValue with source { event, ingest }."),output:s.string().optional().describe('Dot-notation path where hash is stored on the event. Default: "user.hash"'),length:s.number().int().positive().optional().describe("Truncate hash to this length. Default: full 64-char SHA-256 hash")}).describe("Fingerprint transformer: generates deterministic user hashes from event fields"),a=r(n),o={};t(o,{step:()=>p});var p={};t(p,{ipAnonymization:()=>l,missingFields:()=>d,serverFingerprint:()=>g});var g={title:"Server fingerprint",description:"Standard server fingerprint using ingest.ip and ingest.userAgent. Requires source config.ingest.",in:{name:"page view",data:{domain:"www.example.com",title:"Getting Started",id:"/docs/getting-started"},id:"ev-1700000600",trigger:"load",entity:"page",action:"view",timestamp:1700000600,source:{type:"express",platform:"server"}},out:[["return",{event:{name:"page view",data:{domain:"www.example.com",title:"Getting Started",id:"/docs/getting-started"},user:{hash:"158f99cc06e33fd6"},id:"ev-1700000600",trigger:"load",entity:"page",action:"view",timestamp:1700000600,source:{type:"express",platform:"server"}}}]]},d={public:!1,description:"Graceful handling when ingest is missing - fields resolve to empty strings, hash is still generated.",in:{name:"session start",data:{id:"s3ss10n"},id:"ev-1700000601",trigger:"load",entity:"session",action:"start",timestamp:1700000601,source:{type:"express",platform:"server"}},out:[["return",{event:{name:"session start",data:{id:"s3ss10n"},user:{hash:"e183220b699c10a8"},id:"ev-1700000601",trigger:"load",entity:"session",action:"start",timestamp:1700000601,source:{type:"express",platform:"server"}}}]]},l={title:"IP anonymization",description:'Privacy-preserving fingerprint using key+fn pattern: fn truncates IP to /24 subnet before hashing, so 10.0.42.* users share a hash. Config: fields: [{ key: "ingest.ip", fn: ip => ip.replace(/\\.\\d+$/, ".0") }, "ingest.userAgent"]',in:{name:"page view",data:{domain:"www.example.com",title:"Privacy Policy",id:"/privacy"},id:"ev-1700000602",trigger:"load",entity:"page",action:"view",timestamp:1700000602,source:{type:"express",platform:"server"}},out:[["return",{event:{name:"page view",data:{domain:"www.example.com",title:"Privacy Policy",id:"/privacy"},user:{hash:"44d9154b9a9b3792"},id:"ev-1700000602",trigger:"load",entity:"page",action:"view",timestamp:1700000602,source:{type:"express",platform:"server"}}}]]},c={"ingest-prerequisite":{text:'Fields starting with ingest.* require the server source to have config.ingest configured. config.ingest MUST use the map operator with direct request field paths (no req. prefix); a bare object like { ip: "req.ip" } is silently inert and leaves ingest empty. Without populated ingest, all ingest.* fields resolve to empty strings, the hash is still generated but not unique. Always pair this transformer with a source that extracts request metadata.',code:[{lang:"json",code:JSON.stringify({sources:{express:{package:"@walkeros/server-source-express",config:{settings:{port:8080},ingest:{map:{ip:{key:"ip"},userAgent:{key:"headers.user-agent"},origin:{key:"headers.origin"}}}}}},transformers:{fingerprint:{package:"@walkeros/server-transformer-fingerprint",config:{settings:{fields:["ingest.ip","ingest.userAgent"],output:"user.hash",length:16}}}}},null,2)}]},"fields-overview":{text:"Fields resolve from { event, ingest } via walkerOS mapping. Common patterns: ingest.ip (client IP), ingest.userAgent (browser UA), event.data.* (any event property). For time-based rotation use fn fields: daily rotation with toISOString().slice(0,10), monthly with getDate(). Order matters — same fields in different order produce different hashes. Use { key, fn } objects to transform before hashing (e.g., IP anonymization via the ipAnonymization step example)."}};export{o as examples,c as hints,i as schemas};//# sourceMappingURL=dev.mjs.map
1
+ var e=Object.defineProperty,t=(t,i)=>{for(var s in i)e(t,s,{get:i[s],enumerable:!0})},i={};t(i,{SettingsSchema:()=>o,settings:()=>d});import{zodToSchema as s}from"@walkeros/core/dev";import{z as n}from"@walkeros/core/dev";var a=n.union([n.string().describe('Dot-notation path: "ingest.ip", "event.data.userId"'),n.object({key:n.string().optional().describe("Source property path"),value:n.unknown().optional().describe("Static value or fallback"),fn:n.string().optional().describe("$code: function receiving { event, ingest } that returns the value. Runs only when no key is set.")}).describe("Mapping value config for computed fields")]),r=n.union([a,n.literal(!1).describe("Switch this input off")]),o=n.object({salt:n.string().optional().describe('Secret key for the hash (HMAC), e.g. "$env.FINGERPRINT_SALT". Without it the hash can be reversed to the IP; a warning is logged.'),rotate:n.enum(["daily","hourly","none"]).optional().describe('Rotation window in UTC: the same visitor gets a new hash each window. Default: "daily"'),ip:r.optional().describe('Client IP, anonymized to /24 (IPv4) or /48 (IPv6) before hashing. Default: "ingest.ip" when fields is not set'),userAgent:r.optional().describe('User agent, reduced to browser, major version and OS before hashing. Default: "ingest.userAgent" when fields is not set'),site:r.optional().describe('Site the visitor is on; a URL is reduced to its hostname. Default: "event.source.url" when fields is not set'),fields:n.array(a).optional().describe("Extra fields to include in hash (order matters). Each resolved via getMappingValue with source { event, ingest }. When set, named inputs are only used if set explicitly."),output:n.string().optional().describe('Dot-notation path where hash is stored on the event. Default: "user.hash"'),length:n.number().int().positive().optional().describe("Truncate hash to this length. Default: full 64-char SHA-256 hash")}).describe("Fingerprint transformer: a cookieless, privacy-friendly visitor hash"),d=s(o),l={};t(l,{step:()=>h});var h={};t(h,{ipAnonymization:()=>c,missingFields:()=>g,serverFingerprint:()=>p});var p={title:"Server fingerprint",description:'Default server fingerprint: the anonymized ingest.ip, the reduced ingest.userAgent and the site, keyed with settings.salt and rotated daily. Config: { salt: "$env.FINGERPRINT_SALT", length: 16 }. Requires source config.ingest. The hash depends on the salt and the day.',in:{name:"page view",data:{domain:"www.example.com",title:"Getting Started",id:"/docs/getting-started"},id:"ev-1700000600",trigger:"load",entity:"page",action:"view",timestamp:1700000600,source:{type:"express",platform:"server"}},out:[["return",{event:{name:"page view",data:{domain:"www.example.com",title:"Getting Started",id:"/docs/getting-started"},user:{hash:"158f99cc06e33fd6"},id:"ev-1700000600",trigger:"load",entity:"page",action:"view",timestamp:1700000600,source:{type:"express",platform:"server"}}}]]},g={public:!1,description:"Graceful handling when ingest is missing: each empty input logs one warning, and the hash is still generated.",in:{name:"session start",data:{id:"s3ss10n"},id:"ev-1700000601",trigger:"load",entity:"session",action:"start",timestamp:1700000601,source:{type:"express",platform:"server"}},out:[["return",{event:{name:"session start",data:{id:"s3ss10n"},user:{hash:"e183220b699c10a8"},id:"ev-1700000601",trigger:"load",entity:"session",action:"start",timestamp:1700000601,source:{type:"express",platform:"server"}}}]]},c={title:"IP anonymization",description:'The ip input anonymizes the IP before hashing (IPv4 /24, IPv6 /48), so visitors from the same 10.0.42.* subnet with the same browser, major version and OS share a hash within a day. No custom fn needed. Config: { salt: "$env.FINGERPRINT_SALT", length: 16 }',in:{name:"page view",data:{domain:"www.example.com",title:"Privacy Policy",id:"/privacy"},id:"ev-1700000602",trigger:"load",entity:"page",action:"view",timestamp:1700000602,source:{type:"express",platform:"server"}},out:[["return",{event:{name:"page view",data:{domain:"www.example.com",title:"Privacy Policy",id:"/privacy"},user:{hash:"44d9154b9a9b3792"},id:"ev-1700000602",trigger:"load",entity:"page",action:"view",timestamp:1700000602,source:{type:"express",platform:"server"}}}]]},u={"ingest-prerequisite":{text:'The default inputs ip and userAgent read ingest.ip and ingest.userAgent, so the server source must have config.ingest configured. config.ingest MUST use the map operator with direct request field paths (no req. prefix); a bare object like { ip: "req.ip" } is silently inert and leaves ingest empty. An input that resolves empty logs one warning ("Fingerprint input ... resolved empty") and the hash is still generated, but less distinct. Behind a load balancer the request ip is the balancer; read the client from headers.x-forwarded-for instead.',code:[{lang:"json",code:JSON.stringify({sources:{express:{package:"@walkeros/server-source-express",next:"fingerprint",config:{settings:{port:8080},ingest:{map:{ip:{key:"ip"},userAgent:{key:"headers.user-agent"}}}}}},transformers:{fingerprint:{package:"@walkeros/server-transformer-fingerprint",config:{settings:{salt:"$env.FINGERPRINT_SALT"}}}}},null,2)}]},"salt-and-rotation":{text:'The salt keys the hash (HMAC), so it cannot be recomputed or reversed to the IP without it. Pass it via $env so it stays out of the flow file, and use one salt per deployment. Without a salt the transformer runs but logs a warning at startup. Rotation is built in (daily by default, UTC windows): do not add a date field for it. For another window, set rotate: "none" and add the window as a field. Setting fields switches the default inputs off, so name them too: { rotate: "none", ip: "ingest.ip", userAgent: "ingest.userAgent", site: "event.source.url", fields: [{ fn: () => new Date().toISOString().slice(0, 7) }] } rotates monthly. rotate: "none" without such a field keeps one hash per visitor indefinitely, which is a privacy decision, not a default.'},"site-sources":{text:'site defaults to the hostname of event.source.url, which the walkerOS browser source sets on every event. Events without it (GA4 hits decoded by transformer-ga4, server-side events) need site pointed elsewhere: an ingest path such as ingest.property (a path parameter) or ingest.origin (map it in config.ingest first, e.g. origin: { key: "headers.origin" }), or a static { value: "example.com" }. site keeps one visitor apart across sites when one collector with one salt serves several of them; a single-site deployment with its own salt is already separated by the salt.'},"extra-fields":{text:"fields adds values to the hash as they resolve from { event, ingest }, in order. When fields is set, the named inputs ip, userAgent and site are only used if set explicitly, so an existing fields config keeps its input set. To transform a value before hashing, use fn alone: it receives { event, ingest } and returns the value. A fn next to a key never runs. The IP never needs a custom fn: the ip input anonymizes it (IPv4 /24, IPv6 /48)."}};export{l as examples,u as hints,i as schemas};//# sourceMappingURL=dev.mjs.map
package/dist/dev.mjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/schemas/index.ts","../src/schemas/settings.ts","../src/examples/index.ts","../src/examples/step.ts","../src/hints.ts"],"sourcesContent":["import { zodToSchema } from '@walkeros/core/dev';\nimport { SettingsSchema } from './settings';\n\nexport { SettingsSchema, type Settings } from './settings';\nexport const settings = zodToSchema(SettingsSchema);\n","import { z } from '@walkeros/core/dev';\n\n/**\n * Fingerprint transformer settings schema.\n *\n * Mirrors: types.ts FingerprintSettings\n */\nexport const SettingsSchema = z\n .object({\n fields: z\n .array(\n z.union([\n z\n .string()\n .describe('Dot-notation path: \"ingest.ip\", \"event.data.userId\"'),\n z\n .object({\n key: z.string().optional().describe('Source property path'),\n value: z\n .unknown()\n .optional()\n .describe('Static value or fallback'),\n fn: z\n .string()\n .optional()\n .describe('$code: function for value transformation'),\n })\n .describe('Mapping value config for computed fields'),\n ]),\n )\n .describe(\n 'Fields to include in hash (order matters). Each resolved via getMappingValue with source { event, ingest }.',\n ),\n output: z\n .string()\n .optional()\n .describe(\n 'Dot-notation path where hash is stored on the event. Default: \"user.hash\"',\n ),\n length: z\n .number()\n .int()\n .positive()\n .optional()\n .describe(\n 'Truncate hash to this length. Default: full 64-char SHA-256 hash',\n ),\n })\n .describe(\n 'Fingerprint transformer: generates deterministic user hashes from event fields',\n );\n\nexport type Settings = z.infer<typeof SettingsSchema>;\n","export * as step from './step';\n","import type { Flow } from '@walkeros/core';\n\nexport const serverFingerprint: Flow.StepExample = {\n title: 'Server fingerprint',\n description:\n 'Standard server fingerprint using ingest.ip and ingest.userAgent. Requires source config.ingest.',\n in: {\n name: 'page view',\n data: {\n domain: 'www.example.com',\n title: 'Getting Started',\n id: '/docs/getting-started',\n },\n id: 'ev-1700000600',\n trigger: 'load',\n entity: 'page',\n action: 'view',\n timestamp: 1700000600,\n source: { type: 'express', platform: 'server' },\n },\n out: [\n [\n 'return',\n {\n event: {\n name: 'page view',\n data: {\n domain: 'www.example.com',\n title: 'Getting Started',\n id: '/docs/getting-started',\n },\n user: { hash: '158f99cc06e33fd6' },\n id: 'ev-1700000600',\n trigger: 'load',\n entity: 'page',\n action: 'view',\n timestamp: 1700000600,\n source: { type: 'express', platform: 'server' },\n },\n },\n ],\n ],\n};\n\nexport const missingFields: Flow.StepExample = {\n public: false,\n description:\n 'Graceful handling when ingest is missing - fields resolve to empty strings, hash is still generated.',\n in: {\n name: 'session start',\n data: { id: 's3ss10n' },\n id: 'ev-1700000601',\n trigger: 'load',\n entity: 'session',\n action: 'start',\n timestamp: 1700000601,\n source: { type: 'express', platform: 'server' },\n },\n out: [\n [\n 'return',\n {\n event: {\n name: 'session start',\n data: { id: 's3ss10n' },\n user: { hash: 'e183220b699c10a8' },\n id: 'ev-1700000601',\n trigger: 'load',\n entity: 'session',\n action: 'start',\n timestamp: 1700000601,\n source: { type: 'express', platform: 'server' },\n },\n },\n ],\n ],\n};\n\nexport const ipAnonymization: Flow.StepExample = {\n title: 'IP anonymization',\n description:\n 'Privacy-preserving fingerprint using key+fn pattern: ' +\n 'fn truncates IP to /24 subnet before hashing, so 10.0.42.* users share a hash. ' +\n 'Config: fields: [{ key: \"ingest.ip\", fn: ip => ip.replace(/\\\\.\\\\d+$/, \".0\") }, \"ingest.userAgent\"]',\n in: {\n name: 'page view',\n data: {\n domain: 'www.example.com',\n title: 'Privacy Policy',\n id: '/privacy',\n },\n id: 'ev-1700000602',\n trigger: 'load',\n entity: 'page',\n action: 'view',\n timestamp: 1700000602,\n source: { type: 'express', platform: 'server' },\n },\n out: [\n [\n 'return',\n {\n event: {\n name: 'page view',\n data: {\n domain: 'www.example.com',\n title: 'Privacy Policy',\n id: '/privacy',\n },\n user: { hash: '44d9154b9a9b3792' },\n id: 'ev-1700000602',\n trigger: 'load',\n entity: 'page',\n action: 'view',\n timestamp: 1700000602,\n source: { type: 'express', platform: 'server' },\n },\n },\n ],\n ],\n};\n","import type { Hint } from '@walkeros/core';\n\nexport const hints: Hint.Hints = {\n 'ingest-prerequisite': {\n text: 'Fields starting with ingest.* require the server source to have config.ingest configured. config.ingest MUST use the map operator with direct request field paths (no req. prefix); a bare object like { ip: \"req.ip\" } is silently inert and leaves ingest empty. Without populated ingest, all ingest.* fields resolve to empty strings, the hash is still generated but not unique. Always pair this transformer with a source that extracts request metadata.',\n code: [\n {\n lang: 'json',\n code: JSON.stringify(\n {\n sources: {\n express: {\n package: '@walkeros/server-source-express',\n config: {\n settings: { port: 8080 },\n ingest: {\n map: {\n ip: { key: 'ip' },\n userAgent: { key: 'headers.user-agent' },\n origin: { key: 'headers.origin' },\n },\n },\n },\n },\n },\n transformers: {\n fingerprint: {\n package: '@walkeros/server-transformer-fingerprint',\n config: {\n settings: {\n fields: ['ingest.ip', 'ingest.userAgent'],\n output: 'user.hash',\n length: 16,\n },\n },\n },\n },\n },\n null,\n 2,\n ),\n },\n ],\n },\n 'fields-overview': {\n text: 'Fields resolve from { event, ingest } via walkerOS mapping. Common patterns: ingest.ip (client IP), ingest.userAgent (browser UA), event.data.* (any event property). For time-based rotation use fn fields: daily rotation with toISOString().slice(0,10), monthly with getDate(). Order matters — same fields in different order produce different hashes. Use { key, fn } objects to transform before hashing (e.g., IP anonymization via the ipAnonymization step example).',\n },\n};\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/schemas/index.ts","../src/schemas/settings.ts","../src/examples/index.ts","../src/examples/step.ts","../src/hints.ts"],"sourcesContent":["import { zodToSchema } from '@walkeros/core/dev';\nimport { SettingsSchema } from './settings';\n\nexport { SettingsSchema, type Settings } from './settings';\nexport const settings = zodToSchema(SettingsSchema);\n","import { z } from '@walkeros/core/dev';\n\nconst FieldSchema = z.union([\n z.string().describe('Dot-notation path: \"ingest.ip\", \"event.data.userId\"'),\n z\n .object({\n key: z.string().optional().describe('Source property path'),\n value: z.unknown().optional().describe('Static value or fallback'),\n fn: z\n .string()\n .optional()\n .describe(\n '$code: function receiving { event, ingest } that returns the value. Runs only when no key is set.',\n ),\n })\n .describe('Mapping value config for computed fields'),\n]);\n\nconst InputSchema = z.union([\n FieldSchema,\n z.literal(false).describe('Switch this input off'),\n]);\n\n/**\n * Fingerprint transformer settings schema.\n *\n * Mirrors: types.ts FingerprintSettings\n */\nexport const SettingsSchema = z\n .object({\n salt: z\n .string()\n .optional()\n .describe(\n 'Secret key for the hash (HMAC), e.g. \"$env.FINGERPRINT_SALT\". Without it the hash can be reversed to the IP; a warning is logged.',\n ),\n rotate: z\n .enum(['daily', 'hourly', 'none'])\n .optional()\n .describe(\n 'Rotation window in UTC: the same visitor gets a new hash each window. Default: \"daily\"',\n ),\n ip: InputSchema.optional().describe(\n 'Client IP, anonymized to /24 (IPv4) or /48 (IPv6) before hashing. Default: \"ingest.ip\" when fields is not set',\n ),\n userAgent: InputSchema.optional().describe(\n 'User agent, reduced to browser, major version and OS before hashing. Default: \"ingest.userAgent\" when fields is not set',\n ),\n site: InputSchema.optional().describe(\n 'Site the visitor is on; a URL is reduced to its hostname. Default: \"event.source.url\" when fields is not set',\n ),\n fields: z\n .array(FieldSchema)\n .optional()\n .describe(\n 'Extra fields to include in hash (order matters). Each resolved via getMappingValue with source { event, ingest }. When set, named inputs are only used if set explicitly.',\n ),\n output: z\n .string()\n .optional()\n .describe(\n 'Dot-notation path where hash is stored on the event. Default: \"user.hash\"',\n ),\n length: z\n .number()\n .int()\n .positive()\n .optional()\n .describe(\n 'Truncate hash to this length. Default: full 64-char SHA-256 hash',\n ),\n })\n .describe(\n 'Fingerprint transformer: a cookieless, privacy-friendly visitor hash',\n );\n\nexport type Settings = z.infer<typeof SettingsSchema>;\n","export * as step from './step';\n","import type { Flow } from '@walkeros/core';\n\nexport const serverFingerprint: Flow.StepExample = {\n title: 'Server fingerprint',\n description:\n 'Default server fingerprint: the anonymized ingest.ip, the reduced ingest.userAgent and the site, keyed with settings.salt and rotated daily. ' +\n 'Config: { salt: \"$env.FINGERPRINT_SALT\", length: 16 }. Requires source config.ingest. The hash depends on the salt and the day.',\n in: {\n name: 'page view',\n data: {\n domain: 'www.example.com',\n title: 'Getting Started',\n id: '/docs/getting-started',\n },\n id: 'ev-1700000600',\n trigger: 'load',\n entity: 'page',\n action: 'view',\n timestamp: 1700000600,\n source: { type: 'express', platform: 'server' },\n },\n out: [\n [\n 'return',\n {\n event: {\n name: 'page view',\n data: {\n domain: 'www.example.com',\n title: 'Getting Started',\n id: '/docs/getting-started',\n },\n user: { hash: '158f99cc06e33fd6' },\n id: 'ev-1700000600',\n trigger: 'load',\n entity: 'page',\n action: 'view',\n timestamp: 1700000600,\n source: { type: 'express', platform: 'server' },\n },\n },\n ],\n ],\n};\n\nexport const missingFields: Flow.StepExample = {\n public: false,\n description:\n 'Graceful handling when ingest is missing: each empty input logs one warning, and the hash is still generated.',\n in: {\n name: 'session start',\n data: { id: 's3ss10n' },\n id: 'ev-1700000601',\n trigger: 'load',\n entity: 'session',\n action: 'start',\n timestamp: 1700000601,\n source: { type: 'express', platform: 'server' },\n },\n out: [\n [\n 'return',\n {\n event: {\n name: 'session start',\n data: { id: 's3ss10n' },\n user: { hash: 'e183220b699c10a8' },\n id: 'ev-1700000601',\n trigger: 'load',\n entity: 'session',\n action: 'start',\n timestamp: 1700000601,\n source: { type: 'express', platform: 'server' },\n },\n },\n ],\n ],\n};\n\nexport const ipAnonymization: Flow.StepExample = {\n title: 'IP anonymization',\n description:\n 'The ip input anonymizes the IP before hashing (IPv4 /24, IPv6 /48), so visitors from the same 10.0.42.* subnet ' +\n 'with the same browser, major version and OS share a hash within a day. No custom fn needed. Config: { salt: \"$env.FINGERPRINT_SALT\", length: 16 }',\n in: {\n name: 'page view',\n data: {\n domain: 'www.example.com',\n title: 'Privacy Policy',\n id: '/privacy',\n },\n id: 'ev-1700000602',\n trigger: 'load',\n entity: 'page',\n action: 'view',\n timestamp: 1700000602,\n source: { type: 'express', platform: 'server' },\n },\n out: [\n [\n 'return',\n {\n event: {\n name: 'page view',\n data: {\n domain: 'www.example.com',\n title: 'Privacy Policy',\n id: '/privacy',\n },\n user: { hash: '44d9154b9a9b3792' },\n id: 'ev-1700000602',\n trigger: 'load',\n entity: 'page',\n action: 'view',\n timestamp: 1700000602,\n source: { type: 'express', platform: 'server' },\n },\n },\n ],\n ],\n};\n","import type { Hint } from '@walkeros/core';\n\nexport const hints: Hint.Hints = {\n 'ingest-prerequisite': {\n text: 'The default inputs ip and userAgent read ingest.ip and ingest.userAgent, so the server source must have config.ingest configured. config.ingest MUST use the map operator with direct request field paths (no req. prefix); a bare object like { ip: \"req.ip\" } is silently inert and leaves ingest empty. An input that resolves empty logs one warning (\"Fingerprint input ... resolved empty\") and the hash is still generated, but less distinct. Behind a load balancer the request ip is the balancer; read the client from headers.x-forwarded-for instead.',\n code: [\n {\n lang: 'json',\n code: JSON.stringify(\n {\n sources: {\n express: {\n package: '@walkeros/server-source-express',\n next: 'fingerprint',\n config: {\n settings: { port: 8080 },\n ingest: {\n map: {\n ip: { key: 'ip' },\n userAgent: { key: 'headers.user-agent' },\n },\n },\n },\n },\n },\n transformers: {\n fingerprint: {\n package: '@walkeros/server-transformer-fingerprint',\n config: {\n settings: { salt: '$env.FINGERPRINT_SALT' },\n },\n },\n },\n },\n null,\n 2,\n ),\n },\n ],\n },\n 'salt-and-rotation': {\n text: 'The salt keys the hash (HMAC), so it cannot be recomputed or reversed to the IP without it. Pass it via $env so it stays out of the flow file, and use one salt per deployment. Without a salt the transformer runs but logs a warning at startup. Rotation is built in (daily by default, UTC windows): do not add a date field for it. For another window, set rotate: \"none\" and add the window as a field. Setting fields switches the default inputs off, so name them too: { rotate: \"none\", ip: \"ingest.ip\", userAgent: \"ingest.userAgent\", site: \"event.source.url\", fields: [{ fn: () => new Date().toISOString().slice(0, 7) }] } rotates monthly. rotate: \"none\" without such a field keeps one hash per visitor indefinitely, which is a privacy decision, not a default.',\n },\n 'site-sources': {\n text: 'site defaults to the hostname of event.source.url, which the walkerOS browser source sets on every event. Events without it (GA4 hits decoded by transformer-ga4, server-side events) need site pointed elsewhere: an ingest path such as ingest.property (a path parameter) or ingest.origin (map it in config.ingest first, e.g. origin: { key: \"headers.origin\" }), or a static { value: \"example.com\" }. site keeps one visitor apart across sites when one collector with one salt serves several of them; a single-site deployment with its own salt is already separated by the salt.',\n },\n 'extra-fields': {\n text: 'fields adds values to the hash as they resolve from { event, ingest }, in order. When fields is set, the named inputs ip, userAgent and site are only used if set explicitly, so an existing fields config keeps its input set. To transform a value before hashing, use fn alone: it receives { event, ingest } and returns the value. A fn next to a key never runs. The IP never needs a custom fn: the ip input anonymizes it (IPv4 /24, IPv6 /48).',\n },\n};\n"],"mappings":"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","names":[]}
package/dist/index.d.mts CHANGED
@@ -2,22 +2,56 @@ import { Mapping, Transformer } from '@walkeros/core';
2
2
 
3
3
  /**
4
4
  * Fingerprint transformer settings.
5
+ *
6
+ * Without `fields`, the hash is built from the named inputs `ip`,
7
+ * `userAgent` and `site`, each reduced before hashing. With `fields`, only
8
+ * the fields are hashed, plus any named input set explicitly.
5
9
  */
6
10
  interface FingerprintSettings {
7
11
  /**
8
- * Fields to include in hash (order matters!).
12
+ * Secret key for the hash (HMAC), e.g. "$env.FINGERPRINT_SALT".
13
+ * Without it the hash can be reversed to the IP by brute force; the
14
+ * transformer then warns once and hashes unkeyed.
15
+ */
16
+ salt?: string;
17
+ /**
18
+ * Rotation window in UTC. The same visitor gets a new hash each window.
19
+ * @default 'daily'
20
+ */
21
+ rotate?: 'daily' | 'hourly' | 'none';
22
+ /**
23
+ * Client IP, anonymized to /24 (IPv4) or /48 (IPv6) before hashing.
24
+ * `false` switches it off.
25
+ * @default 'ingest.ip' (only when `fields` is not set)
26
+ */
27
+ ip?: Mapping.Value | false;
28
+ /**
29
+ * User agent, reduced to browser, major version and OS before hashing.
30
+ * `false` switches it off.
31
+ * @default 'ingest.userAgent' (only when `fields` is not set)
32
+ */
33
+ userAgent?: Mapping.Value | false;
34
+ /**
35
+ * Site the visitor is on, so one visitor gets different hashes on
36
+ * different sites. A URL is reduced to its hostname. Use an ingest path
37
+ * (e.g. 'ingest.property') or a static `{ value: 'example.com' }` when
38
+ * events carry no page URL. `false` switches it off.
39
+ * @default 'event.source.url' (only when `fields` is not set)
40
+ */
41
+ site?: Mapping.Value | false;
42
+ /**
43
+ * Extra fields to include in the hash (order matters!), hashed as resolved.
9
44
  * Each field is resolved via getMappingValue with source object { event, ingest }.
10
45
  *
11
46
  * String fields use dot notation:
12
47
  * - 'ingest.ip' -> context.ingest.ip
13
- * - 'ingest.userAgent' -> context.ingest.userAgent
14
48
  * - 'event.data.userId' -> event.data.userId
15
49
  *
16
50
  * Function fields via mapping config:
17
- * - { fn: () => new Date().getDate() } -> day of month
18
- * - { key: 'ingest.ip', fn: anonymizeIP } -> transform value
51
+ * - { fn: ({ ingest }) => ingest.region } -> computed value
52
+ * (fn receives { event, ingest }; a fn next to a key never runs)
19
53
  */
20
- fields: Mapping.Value[];
54
+ fields?: Mapping.Value[];
21
55
  /**
22
56
  * Dot-notation path where hash is stored on the event.
23
57
  * @default 'user.hash'
@@ -31,23 +65,16 @@ interface FingerprintSettings {
31
65
  }
32
66
 
33
67
  /**
34
- * Fingerprint transformer - hash configurable fields for session continuity.
68
+ * Fingerprint transformer - a cookieless, privacy-friendly visitor hash.
35
69
  *
36
- * Resolves fields from { event, ingest } source object using getMappingValue,
37
- * concatenates values in order, hashes with SHA-256, and stores at output path.
70
+ * Hashes the rotation window, the named inputs (anonymized IP, reduced user
71
+ * agent, site) and any extra fields with an HMAC keyed by the salt, and
72
+ * stores the result at the output path.
38
73
  *
39
74
  * @example
40
75
  * transformerFingerprint({
41
76
  * config: {
42
- * settings: {
43
- * fields: [
44
- * 'ingest.ip',
45
- * 'ingest.userAgent',
46
- * { fn: () => new Date().getDate() },
47
- * ],
48
- * output: 'user.hash',
49
- * length: 16,
50
- * },
77
+ * settings: { salt: '$env.FINGERPRINT_SALT' },
51
78
  * },
52
79
  * })
53
80
  */
package/dist/index.d.ts CHANGED
@@ -2,22 +2,56 @@ import { Mapping, Transformer } from '@walkeros/core';
2
2
 
3
3
  /**
4
4
  * Fingerprint transformer settings.
5
+ *
6
+ * Without `fields`, the hash is built from the named inputs `ip`,
7
+ * `userAgent` and `site`, each reduced before hashing. With `fields`, only
8
+ * the fields are hashed, plus any named input set explicitly.
5
9
  */
6
10
  interface FingerprintSettings {
7
11
  /**
8
- * Fields to include in hash (order matters!).
12
+ * Secret key for the hash (HMAC), e.g. "$env.FINGERPRINT_SALT".
13
+ * Without it the hash can be reversed to the IP by brute force; the
14
+ * transformer then warns once and hashes unkeyed.
15
+ */
16
+ salt?: string;
17
+ /**
18
+ * Rotation window in UTC. The same visitor gets a new hash each window.
19
+ * @default 'daily'
20
+ */
21
+ rotate?: 'daily' | 'hourly' | 'none';
22
+ /**
23
+ * Client IP, anonymized to /24 (IPv4) or /48 (IPv6) before hashing.
24
+ * `false` switches it off.
25
+ * @default 'ingest.ip' (only when `fields` is not set)
26
+ */
27
+ ip?: Mapping.Value | false;
28
+ /**
29
+ * User agent, reduced to browser, major version and OS before hashing.
30
+ * `false` switches it off.
31
+ * @default 'ingest.userAgent' (only when `fields` is not set)
32
+ */
33
+ userAgent?: Mapping.Value | false;
34
+ /**
35
+ * Site the visitor is on, so one visitor gets different hashes on
36
+ * different sites. A URL is reduced to its hostname. Use an ingest path
37
+ * (e.g. 'ingest.property') or a static `{ value: 'example.com' }` when
38
+ * events carry no page URL. `false` switches it off.
39
+ * @default 'event.source.url' (only when `fields` is not set)
40
+ */
41
+ site?: Mapping.Value | false;
42
+ /**
43
+ * Extra fields to include in the hash (order matters!), hashed as resolved.
9
44
  * Each field is resolved via getMappingValue with source object { event, ingest }.
10
45
  *
11
46
  * String fields use dot notation:
12
47
  * - 'ingest.ip' -> context.ingest.ip
13
- * - 'ingest.userAgent' -> context.ingest.userAgent
14
48
  * - 'event.data.userId' -> event.data.userId
15
49
  *
16
50
  * Function fields via mapping config:
17
- * - { fn: () => new Date().getDate() } -> day of month
18
- * - { key: 'ingest.ip', fn: anonymizeIP } -> transform value
51
+ * - { fn: ({ ingest }) => ingest.region } -> computed value
52
+ * (fn receives { event, ingest }; a fn next to a key never runs)
19
53
  */
20
- fields: Mapping.Value[];
54
+ fields?: Mapping.Value[];
21
55
  /**
22
56
  * Dot-notation path where hash is stored on the event.
23
57
  * @default 'user.hash'
@@ -31,23 +65,16 @@ interface FingerprintSettings {
31
65
  }
32
66
 
33
67
  /**
34
- * Fingerprint transformer - hash configurable fields for session continuity.
68
+ * Fingerprint transformer - a cookieless, privacy-friendly visitor hash.
35
69
  *
36
- * Resolves fields from { event, ingest } source object using getMappingValue,
37
- * concatenates values in order, hashes with SHA-256, and stores at output path.
70
+ * Hashes the rotation window, the named inputs (anonymized IP, reduced user
71
+ * agent, site) and any extra fields with an HMAC keyed by the salt, and
72
+ * stores the result at the output path.
38
73
  *
39
74
  * @example
40
75
  * transformerFingerprint({
41
76
  * config: {
42
- * settings: {
43
- * fields: [
44
- * 'ingest.ip',
45
- * 'ingest.userAgent',
46
- * { fn: () => new Date().getDate() },
47
- * ],
48
- * output: 'user.hash',
49
- * length: 16,
50
- * },
77
+ * settings: { salt: '$env.FINGERPRINT_SALT' },
51
78
  * },
52
79
  * })
53
80
  */
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- "use strict";var e,r=Object.defineProperty,t=Object.getOwnPropertyDescriptor,o=Object.getOwnPropertyNames,n=Object.prototype.hasOwnProperty,a={};((e,t)=>{for(var o in t)r(e,o,{get:t[o],enumerable:!0})})(a,{default:()=>l,transformerFingerprint:()=>l}),module.exports=(e=a,((e,a,s,i)=>{if(a&&"object"==typeof a||"function"==typeof a)for(let l of o(a))n.call(e,l)||l===s||r(e,l,{get:()=>a[l],enumerable:!(i=t(a,l))||i.enumerable});return e})(r({},"__esModule",{value:!0}),e));var s=require("@walkeros/core"),i=require("@walkeros/server-core"),l=e=>{const{config:r}=e,t=r.settings??{},o=t.fields||[],n=t.output||"user.hash",a=t.length;return{type:"fingerprint",config:r,async push(e,r){const{ingest:t,collector:l}=r,c={event:e,ingest:t},p=""+(await Promise.all(o.map(e=>(0,s.getMappingValue)(c,e,{collector:l})))).map(e=>String(e??"")).join(""),u=await(0,i.getHashServer)(p,a);return{event:(0,s.setByPath)(e,n,u)}}}};//# sourceMappingURL=index.js.map
1
+ "use strict";var e,t=Object.defineProperty,r=Object.getOwnPropertyDescriptor,n=Object.getOwnPropertyNames,s=Object.prototype.hasOwnProperty,o={};((e,r)=>{for(var n in r)t(e,n,{get:r[n],enumerable:!0})})(o,{default:()=>p,transformerFingerprint:()=>p}),module.exports=(e=o,((e,o,a,i)=>{if(o&&"object"==typeof o||"function"==typeof o)for(let c of n(o))s.call(e,c)||c===a||t(e,c,{get:()=>o[c],enumerable:!(i=r(o,c))||i.enumerable});return e})(t({},"__esModule",{value:!0}),e));var a=require("@walkeros/core"),i=require("@walkeros/server-core"),c=["ip","userAgent","site"],u={ip:"ingest.ip",userAgent:"ingest.userAgent",site:"event.source.url"},l={ip:a.anonymizeIP,userAgent:e=>[(0,a.getBrowser)(e),(0,a.getBrowserVersion)(e),(0,a.getOS)(e)].map(e=>e??"").join("/"),site:function(e){try{return new URL(e).hostname||e}catch{return e}}};function g(e){if("none"===e)return"";const t=(new Date).toISOString();return"hourly"===e?t.slice(0,13):t.slice(0,10)}var p=e=>{const{config:t,logger:r}=e,n=t.settings??{},s=n.fields||[],o=n.output||"user.hash",p=n.length,f=n.salt||void 0,h=n.rotate??"daily",y=[];for(const e of c){const t=n[e]??(!n.fields&&u[e]);!1!==t&&y.push({name:e,value:t})}f||r.warn('Fingerprint has no salt: the hash can be reversed to the IP by brute force. Set settings.salt, e.g. "$env.FINGERPRINT_SALT".');const w=new Set;return{type:"fingerprint",config:t,async push(e,t){const{ingest:n,collector:c}=t,u=(0,a.createMappingRoot)(n,e),m=async e=>String(await(0,a.getMappingValue)(u,e,{collector:c})??""),v=await Promise.all(y.map(async({name:e,value:t})=>{const n=await m(t),s=n?l[e](n):"";return s||w.has(e)||(w.add(e),r.warn(`Fingerprint input "${e}" resolved empty, so hashes are less distinct. Check the source's config.ingest or set settings.${e}.`)),s})),d=await Promise.all(s.map(m)),b=[g(h),...v,...d].join(""),P=await(0,i.getHashServer)(b,p,f?{key:f}:{});return{event:(0,a.setByPath)(e,o,P)}}}};//# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/transformer.ts"],"sourcesContent":["export { transformerFingerprint } from './transformer';\nexport type { FingerprintSettings } from './types';\n\nexport { transformerFingerprint as default } from './transformer';\n","import type { Mapping, Transformer } from '@walkeros/core';\nimport { getMappingValue, setByPath } from '@walkeros/core';\nimport { getHashServer } from '@walkeros/server-core';\nimport type { FingerprintSettings } from './types';\n\n/**\n * Fingerprint transformer - hash configurable fields for session continuity.\n *\n * Resolves fields from { event, ingest } source object using getMappingValue,\n * concatenates values in order, hashes with SHA-256, and stores at output path.\n *\n * @example\n * transformerFingerprint({\n * config: {\n * settings: {\n * fields: [\n * 'ingest.ip',\n * 'ingest.userAgent',\n * { fn: () => new Date().getDate() },\n * ],\n * output: 'user.hash',\n * length: 16,\n * },\n * },\n * })\n */\nexport const transformerFingerprint: Transformer.Init<\n Transformer.Types<FingerprintSettings>\n> = (context) => {\n const { config } = context;\n const settings: Partial<FingerprintSettings> = config.settings ?? {};\n const fields: Mapping.Value[] = settings.fields || [];\n const output: string = settings.output || 'user.hash';\n const length: number | undefined = settings.length;\n\n return {\n type: 'fingerprint',\n config: config as Transformer.Config<\n Transformer.Types<FingerprintSettings>\n >,\n\n async push(event, context) {\n const { ingest, collector } = context;\n\n // Build source object for field resolution\n const source = { event, ingest };\n\n // Resolve each field via mapping (maintains order)\n const values = await Promise.all(\n fields.map((field: Mapping.Value) =>\n getMappingValue(source, field, { collector }),\n ),\n );\n\n // Safe string concatenation: '' prefix + String() cast for each value\n // '' prefix ensures we always have a string even if fields is empty\n // String(v ?? '') handles undefined/null gracefully\n const input = '' + values.map((v: unknown) => String(v ?? '')).join('');\n\n // Hash and store at output path\n const hash = await getHashServer(input, length);\n return { event: setByPath(event, output, hash) };\n },\n };\n};\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACCA,kBAA2C;AAC3C,yBAA8B;AAwBvB,IAAM,yBAET,CAAC,YAAY;AACf,QAAM,EAAE,OAAO,IAAI;AACnB,QAAM,WAAyC,OAAO,YAAY,CAAC;AACnE,QAAM,SAA0B,SAAS,UAAU,CAAC;AACpD,QAAM,SAAiB,SAAS,UAAU;AAC1C,QAAM,SAA6B,SAAS;AAE5C,SAAO;AAAA,IACL,MAAM;AAAA,IACN;AAAA,IAIA,MAAM,KAAK,OAAOA,UAAS;AACzB,YAAM,EAAE,QAAQ,UAAU,IAAIA;AAG9B,YAAM,SAAS,EAAE,OAAO,OAAO;AAG/B,YAAM,SAAS,MAAM,QAAQ;AAAA,QAC3B,OAAO;AAAA,UAAI,CAAC,cACV,6BAAgB,QAAQ,OAAO,EAAE,UAAU,CAAC;AAAA,QAC9C;AAAA,MACF;AAKA,YAAM,QAAQ,KAAK,OAAO,IAAI,CAAC,MAAe,OAAO,KAAK,EAAE,CAAC,EAAE,KAAK,EAAE;AAGtE,YAAM,OAAO,UAAM,kCAAc,OAAO,MAAM;AAC9C,aAAO,EAAE,WAAO,uBAAU,OAAO,QAAQ,IAAI,EAAE;AAAA,IACjD;AAAA,EACF;AACF;","names":["context"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/transformer.ts"],"sourcesContent":["export { transformerFingerprint } from './transformer';\nexport type { FingerprintSettings } from './types';\n\nexport { transformerFingerprint as default } from './transformer';\n","import type { Mapping, Transformer } from '@walkeros/core';\nimport {\n anonymizeIP,\n createMappingRoot,\n getBrowser,\n getBrowserVersion,\n getMappingValue,\n getOS,\n setByPath,\n} from '@walkeros/core';\nimport { getHashServer } from '@walkeros/server-core';\nimport type { FingerprintSettings } from './types';\n\ntype InputName = 'ip' | 'userAgent' | 'site';\n\ninterface Input {\n name: InputName;\n value: Mapping.Value;\n}\n\nconst inputNames: InputName[] = ['ip', 'userAgent', 'site'];\n\nconst defaultInputs: Record<InputName, Mapping.Value> = {\n ip: 'ingest.ip',\n userAgent: 'ingest.userAgent',\n site: 'event.source.url',\n};\n\n// Each named input is reduced before hashing, so the hash never carries\n// more than the reduced value.\nconst reduceInput: Record<InputName, (value: string) => string> = {\n ip: anonymizeIP,\n userAgent: (ua) =>\n [getBrowser(ua), getBrowserVersion(ua), getOS(ua)]\n .map((part) => part ?? '')\n .join('/'),\n site: toHostname,\n};\n\nfunction toHostname(value: string): string {\n try {\n return new URL(value).hostname || value;\n } catch {\n return value; // Not a URL, e.g. a site id\n }\n}\n\nfunction rotationWindow(rotate: FingerprintSettings['rotate']): string {\n if (rotate === 'none') return '';\n const now = new Date().toISOString();\n return rotate === 'hourly' ? now.slice(0, 13) : now.slice(0, 10);\n}\n\n/**\n * Fingerprint transformer - a cookieless, privacy-friendly visitor hash.\n *\n * Hashes the rotation window, the named inputs (anonymized IP, reduced user\n * agent, site) and any extra fields with an HMAC keyed by the salt, and\n * stores the result at the output path.\n *\n * @example\n * transformerFingerprint({\n * config: {\n * settings: { salt: '$env.FINGERPRINT_SALT' },\n * },\n * })\n */\nexport const transformerFingerprint: Transformer.Init<\n Transformer.Types<FingerprintSettings>\n> = (context) => {\n const { config, logger } = context;\n const settings: Partial<FingerprintSettings> = config.settings ?? {};\n const fields: Mapping.Value[] = settings.fields || [];\n const output: string = settings.output || 'user.hash';\n const length: number | undefined = settings.length;\n const salt: string | undefined = settings.salt || undefined;\n const rotate = settings.rotate ?? 'daily';\n\n // Named inputs default on only without fields, so a fields config keeps its input set\n const inputs: Input[] = [];\n for (const name of inputNames) {\n const value =\n settings[name] ?? (settings.fields ? false : defaultInputs[name]);\n if (value !== false) inputs.push({ name, value });\n }\n\n if (!salt)\n logger.warn(\n 'Fingerprint has no salt: the hash can be reversed to the IP by brute force. Set settings.salt, e.g. \"$env.FINGERPRINT_SALT\".',\n );\n\n const warnedEmpty = new Set<InputName>();\n\n return {\n type: 'fingerprint',\n config: config as Transformer.Config<\n Transformer.Types<FingerprintSettings>\n >,\n\n async push(event, context) {\n const { ingest, collector } = context;\n\n const source = createMappingRoot(ingest, event);\n const resolve = async (value: Mapping.Value) =>\n String((await getMappingValue(source, value, { collector })) ?? '');\n\n const inputValues = await Promise.all(\n inputs.map(async ({ name, value }) => {\n const resolved = await resolve(value);\n const reduced = resolved ? reduceInput[name](resolved) : '';\n\n if (!reduced && !warnedEmpty.has(name)) {\n warnedEmpty.add(name);\n logger.warn(\n `Fingerprint input \"${name}\" resolved empty, so hashes are less distinct. Check the source's config.ingest or set settings.${name}.`,\n );\n }\n\n return reduced;\n }),\n );\n\n const fieldValues = await Promise.all(fields.map(resolve));\n\n // A separator keeps 'ab' + 'c' and 'a' + 'bc' apart\n const input = [\n rotationWindow(rotate),\n ...inputValues,\n ...fieldValues,\n ].join('\\u001f');\n\n const hash = await getHashServer(\n input,\n length,\n salt ? { key: salt } : {},\n );\n return { event: setByPath(event, output, hash) };\n },\n };\n};\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACCA,kBAQO;AACP,yBAA8B;AAU9B,IAAM,aAA0B,CAAC,MAAM,aAAa,MAAM;AAE1D,IAAM,gBAAkD;AAAA,EACtD,IAAI;AAAA,EACJ,WAAW;AAAA,EACX,MAAM;AACR;AAIA,IAAM,cAA4D;AAAA,EAChE,IAAI;AAAA,EACJ,WAAW,CAAC,OACV,KAAC,wBAAW,EAAE,OAAG,+BAAkB,EAAE,OAAG,mBAAM,EAAE,CAAC,EAC9C,IAAI,CAAC,SAAS,QAAQ,EAAE,EACxB,KAAK,GAAG;AAAA,EACb,MAAM;AACR;AAEA,SAAS,WAAW,OAAuB;AACzC,MAAI;AACF,WAAO,IAAI,IAAI,KAAK,EAAE,YAAY;AAAA,EACpC,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,eAAe,QAA+C;AACrE,MAAI,WAAW,OAAQ,QAAO;AAC9B,QAAM,OAAM,oBAAI,KAAK,GAAE,YAAY;AACnC,SAAO,WAAW,WAAW,IAAI,MAAM,GAAG,EAAE,IAAI,IAAI,MAAM,GAAG,EAAE;AACjE;AAgBO,IAAM,yBAET,CAAC,YAAY;AACf,QAAM,EAAE,QAAQ,OAAO,IAAI;AAC3B,QAAM,WAAyC,OAAO,YAAY,CAAC;AACnE,QAAM,SAA0B,SAAS,UAAU,CAAC;AACpD,QAAM,SAAiB,SAAS,UAAU;AAC1C,QAAM,SAA6B,SAAS;AAC5C,QAAM,OAA2B,SAAS,QAAQ;AAClD,QAAM,SAAS,SAAS,UAAU;AAGlC,QAAM,SAAkB,CAAC;AACzB,aAAW,QAAQ,YAAY;AAC7B,UAAM,QACJ,SAAS,IAAI,MAAM,SAAS,SAAS,QAAQ,cAAc,IAAI;AACjE,QAAI,UAAU,MAAO,QAAO,KAAK,EAAE,MAAM,MAAM,CAAC;AAAA,EAClD;AAEA,MAAI,CAAC;AACH,WAAO;AAAA,MACL;AAAA,IACF;AAEF,QAAM,cAAc,oBAAI,IAAe;AAEvC,SAAO;AAAA,IACL,MAAM;AAAA,IACN;AAAA,IAIA,MAAM,KAAK,OAAOA,UAAS;AACzB,YAAM,EAAE,QAAQ,UAAU,IAAIA;AAE9B,YAAM,aAAS,+BAAkB,QAAQ,KAAK;AAC9C,YAAM,UAAU,OAAO,UACrB,OAAQ,UAAM,6BAAgB,QAAQ,OAAO,EAAE,UAAU,CAAC,KAAM,EAAE;AAEpE,YAAM,cAAc,MAAM,QAAQ;AAAA,QAChC,OAAO,IAAI,OAAO,EAAE,MAAM,MAAM,MAAM;AACpC,gBAAM,WAAW,MAAM,QAAQ,KAAK;AACpC,gBAAM,UAAU,WAAW,YAAY,IAAI,EAAE,QAAQ,IAAI;AAEzD,cAAI,CAAC,WAAW,CAAC,YAAY,IAAI,IAAI,GAAG;AACtC,wBAAY,IAAI,IAAI;AACpB,mBAAO;AAAA,cACL,sBAAsB,IAAI,mGAAmG,IAAI;AAAA,YACnI;AAAA,UACF;AAEA,iBAAO;AAAA,QACT,CAAC;AAAA,MACH;AAEA,YAAM,cAAc,MAAM,QAAQ,IAAI,OAAO,IAAI,OAAO,CAAC;AAGzD,YAAM,QAAQ;AAAA,QACZ,eAAe,MAAM;AAAA,QACrB,GAAG;AAAA,QACH,GAAG;AAAA,MACL,EAAE,KAAK,GAAQ;AAEf,YAAM,OAAO,UAAM;AAAA,QACjB;AAAA,QACA;AAAA,QACA,OAAO,EAAE,KAAK,KAAK,IAAI,CAAC;AAAA,MAC1B;AACA,aAAO,EAAE,WAAO,uBAAU,OAAO,QAAQ,IAAI,EAAE;AAAA,IACjD;AAAA,EACF;AACF;","names":["context"]}
package/dist/index.mjs CHANGED
@@ -1 +1 @@
1
- import{getMappingValue as e,setByPath as t}from"@walkeros/core";import{getHashServer as r}from"@walkeros/server-core";var o=o=>{const{config:n}=o,s=n.settings??{},i=s.fields||[],a=s.output||"user.hash",c=s.length;return{type:"fingerprint",config:n,async push(o,n){const{ingest:s,collector:l}=n,p={event:o,ingest:s},g=""+(await Promise.all(i.map(t=>e(p,t,{collector:l})))).map(e=>String(e??"")).join(""),f=await r(g,c);return{event:t(o,a,f)}}}};export{o as default,o as transformerFingerprint};//# sourceMappingURL=index.mjs.map
1
+ import{anonymizeIP as e,createMappingRoot as t,getBrowser as n,getBrowserVersion as r,getMappingValue as s,getOS as i,setByPath as o}from"@walkeros/core";import{getHashServer as a}from"@walkeros/server-core";var c=["ip","userAgent","site"],l={ip:"ingest.ip",userAgent:"ingest.userAgent",site:"event.source.url"},u={ip:e,userAgent:e=>[n(e),r(e),i(e)].map(e=>e??"").join("/"),site:function(e){try{return new URL(e).hostname||e}catch{return e}}};function g(e){if("none"===e)return"";const t=(new Date).toISOString();return"hourly"===e?t.slice(0,13):t.slice(0,10)}var p=e=>{const{config:n,logger:r}=e,i=n.settings??{},p=i.fields||[],h=i.output||"user.hash",f=i.length,m=i.salt||void 0,w=i.rotate??"daily",v=[];for(const e of c){const t=i[e]??(!i.fields&&l[e]);!1!==t&&v.push({name:e,value:t})}m||r.warn('Fingerprint has no salt: the hash can be reversed to the IP by brute force. Set settings.salt, e.g. "$env.FINGERPRINT_SALT".');const d=new Set;return{type:"fingerprint",config:n,async push(e,n){const{ingest:i,collector:c}=n,l=t(i,e),y=async e=>String(await s(l,e,{collector:c})??""),S=await Promise.all(v.map(async({name:e,value:t})=>{const n=await y(t),s=n?u[e](n):"";return s||d.has(e)||(d.add(e),r.warn(`Fingerprint input "${e}" resolved empty, so hashes are less distinct. Check the source's config.ingest or set settings.${e}.`)),s})),A=await Promise.all(p.map(y)),k=[g(w),...S,...A].join(""),I=await a(k,f,m?{key:m}:{});return{event:o(e,h,I)}}}};export{p as default,p as transformerFingerprint};//# sourceMappingURL=index.mjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/transformer.ts"],"sourcesContent":["import type { Mapping, Transformer } from '@walkeros/core';\nimport { getMappingValue, setByPath } from '@walkeros/core';\nimport { getHashServer } from '@walkeros/server-core';\nimport type { FingerprintSettings } from './types';\n\n/**\n * Fingerprint transformer - hash configurable fields for session continuity.\n *\n * Resolves fields from { event, ingest } source object using getMappingValue,\n * concatenates values in order, hashes with SHA-256, and stores at output path.\n *\n * @example\n * transformerFingerprint({\n * config: {\n * settings: {\n * fields: [\n * 'ingest.ip',\n * 'ingest.userAgent',\n * { fn: () => new Date().getDate() },\n * ],\n * output: 'user.hash',\n * length: 16,\n * },\n * },\n * })\n */\nexport const transformerFingerprint: Transformer.Init<\n Transformer.Types<FingerprintSettings>\n> = (context) => {\n const { config } = context;\n const settings: Partial<FingerprintSettings> = config.settings ?? {};\n const fields: Mapping.Value[] = settings.fields || [];\n const output: string = settings.output || 'user.hash';\n const length: number | undefined = settings.length;\n\n return {\n type: 'fingerprint',\n config: config as Transformer.Config<\n Transformer.Types<FingerprintSettings>\n >,\n\n async push(event, context) {\n const { ingest, collector } = context;\n\n // Build source object for field resolution\n const source = { event, ingest };\n\n // Resolve each field via mapping (maintains order)\n const values = await Promise.all(\n fields.map((field: Mapping.Value) =>\n getMappingValue(source, field, { collector }),\n ),\n );\n\n // Safe string concatenation: '' prefix + String() cast for each value\n // '' prefix ensures we always have a string even if fields is empty\n // String(v ?? '') handles undefined/null gracefully\n const input = '' + values.map((v: unknown) => String(v ?? '')).join('');\n\n // Hash and store at output path\n const hash = await getHashServer(input, length);\n return { event: setByPath(event, output, hash) };\n },\n };\n};\n"],"mappings":";AACA,SAAS,iBAAiB,iBAAiB;AAC3C,SAAS,qBAAqB;AAwBvB,IAAM,yBAET,CAAC,YAAY;AACf,QAAM,EAAE,OAAO,IAAI;AACnB,QAAM,WAAyC,OAAO,YAAY,CAAC;AACnE,QAAM,SAA0B,SAAS,UAAU,CAAC;AACpD,QAAM,SAAiB,SAAS,UAAU;AAC1C,QAAM,SAA6B,SAAS;AAE5C,SAAO;AAAA,IACL,MAAM;AAAA,IACN;AAAA,IAIA,MAAM,KAAK,OAAOA,UAAS;AACzB,YAAM,EAAE,QAAQ,UAAU,IAAIA;AAG9B,YAAM,SAAS,EAAE,OAAO,OAAO;AAG/B,YAAM,SAAS,MAAM,QAAQ;AAAA,QAC3B,OAAO;AAAA,UAAI,CAAC,UACV,gBAAgB,QAAQ,OAAO,EAAE,UAAU,CAAC;AAAA,QAC9C;AAAA,MACF;AAKA,YAAM,QAAQ,KAAK,OAAO,IAAI,CAAC,MAAe,OAAO,KAAK,EAAE,CAAC,EAAE,KAAK,EAAE;AAGtE,YAAM,OAAO,MAAM,cAAc,OAAO,MAAM;AAC9C,aAAO,EAAE,OAAO,UAAU,OAAO,QAAQ,IAAI,EAAE;AAAA,IACjD;AAAA,EACF;AACF;","names":["context"]}
1
+ {"version":3,"sources":["../src/transformer.ts"],"sourcesContent":["import type { Mapping, Transformer } from '@walkeros/core';\nimport {\n anonymizeIP,\n createMappingRoot,\n getBrowser,\n getBrowserVersion,\n getMappingValue,\n getOS,\n setByPath,\n} from '@walkeros/core';\nimport { getHashServer } from '@walkeros/server-core';\nimport type { FingerprintSettings } from './types';\n\ntype InputName = 'ip' | 'userAgent' | 'site';\n\ninterface Input {\n name: InputName;\n value: Mapping.Value;\n}\n\nconst inputNames: InputName[] = ['ip', 'userAgent', 'site'];\n\nconst defaultInputs: Record<InputName, Mapping.Value> = {\n ip: 'ingest.ip',\n userAgent: 'ingest.userAgent',\n site: 'event.source.url',\n};\n\n// Each named input is reduced before hashing, so the hash never carries\n// more than the reduced value.\nconst reduceInput: Record<InputName, (value: string) => string> = {\n ip: anonymizeIP,\n userAgent: (ua) =>\n [getBrowser(ua), getBrowserVersion(ua), getOS(ua)]\n .map((part) => part ?? '')\n .join('/'),\n site: toHostname,\n};\n\nfunction toHostname(value: string): string {\n try {\n return new URL(value).hostname || value;\n } catch {\n return value; // Not a URL, e.g. a site id\n }\n}\n\nfunction rotationWindow(rotate: FingerprintSettings['rotate']): string {\n if (rotate === 'none') return '';\n const now = new Date().toISOString();\n return rotate === 'hourly' ? now.slice(0, 13) : now.slice(0, 10);\n}\n\n/**\n * Fingerprint transformer - a cookieless, privacy-friendly visitor hash.\n *\n * Hashes the rotation window, the named inputs (anonymized IP, reduced user\n * agent, site) and any extra fields with an HMAC keyed by the salt, and\n * stores the result at the output path.\n *\n * @example\n * transformerFingerprint({\n * config: {\n * settings: { salt: '$env.FINGERPRINT_SALT' },\n * },\n * })\n */\nexport const transformerFingerprint: Transformer.Init<\n Transformer.Types<FingerprintSettings>\n> = (context) => {\n const { config, logger } = context;\n const settings: Partial<FingerprintSettings> = config.settings ?? {};\n const fields: Mapping.Value[] = settings.fields || [];\n const output: string = settings.output || 'user.hash';\n const length: number | undefined = settings.length;\n const salt: string | undefined = settings.salt || undefined;\n const rotate = settings.rotate ?? 'daily';\n\n // Named inputs default on only without fields, so a fields config keeps its input set\n const inputs: Input[] = [];\n for (const name of inputNames) {\n const value =\n settings[name] ?? (settings.fields ? false : defaultInputs[name]);\n if (value !== false) inputs.push({ name, value });\n }\n\n if (!salt)\n logger.warn(\n 'Fingerprint has no salt: the hash can be reversed to the IP by brute force. Set settings.salt, e.g. \"$env.FINGERPRINT_SALT\".',\n );\n\n const warnedEmpty = new Set<InputName>();\n\n return {\n type: 'fingerprint',\n config: config as Transformer.Config<\n Transformer.Types<FingerprintSettings>\n >,\n\n async push(event, context) {\n const { ingest, collector } = context;\n\n const source = createMappingRoot(ingest, event);\n const resolve = async (value: Mapping.Value) =>\n String((await getMappingValue(source, value, { collector })) ?? '');\n\n const inputValues = await Promise.all(\n inputs.map(async ({ name, value }) => {\n const resolved = await resolve(value);\n const reduced = resolved ? reduceInput[name](resolved) : '';\n\n if (!reduced && !warnedEmpty.has(name)) {\n warnedEmpty.add(name);\n logger.warn(\n `Fingerprint input \"${name}\" resolved empty, so hashes are less distinct. Check the source's config.ingest or set settings.${name}.`,\n );\n }\n\n return reduced;\n }),\n );\n\n const fieldValues = await Promise.all(fields.map(resolve));\n\n // A separator keeps 'ab' + 'c' and 'a' + 'bc' apart\n const input = [\n rotationWindow(rotate),\n ...inputValues,\n ...fieldValues,\n ].join('\\u001f');\n\n const hash = await getHashServer(\n input,\n length,\n salt ? { key: salt } : {},\n );\n return { event: setByPath(event, output, hash) };\n },\n };\n};\n"],"mappings":";AACA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,qBAAqB;AAU9B,IAAM,aAA0B,CAAC,MAAM,aAAa,MAAM;AAE1D,IAAM,gBAAkD;AAAA,EACtD,IAAI;AAAA,EACJ,WAAW;AAAA,EACX,MAAM;AACR;AAIA,IAAM,cAA4D;AAAA,EAChE,IAAI;AAAA,EACJ,WAAW,CAAC,OACV,CAAC,WAAW,EAAE,GAAG,kBAAkB,EAAE,GAAG,MAAM,EAAE,CAAC,EAC9C,IAAI,CAAC,SAAS,QAAQ,EAAE,EACxB,KAAK,GAAG;AAAA,EACb,MAAM;AACR;AAEA,SAAS,WAAW,OAAuB;AACzC,MAAI;AACF,WAAO,IAAI,IAAI,KAAK,EAAE,YAAY;AAAA,EACpC,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,eAAe,QAA+C;AACrE,MAAI,WAAW,OAAQ,QAAO;AAC9B,QAAM,OAAM,oBAAI,KAAK,GAAE,YAAY;AACnC,SAAO,WAAW,WAAW,IAAI,MAAM,GAAG,EAAE,IAAI,IAAI,MAAM,GAAG,EAAE;AACjE;AAgBO,IAAM,yBAET,CAAC,YAAY;AACf,QAAM,EAAE,QAAQ,OAAO,IAAI;AAC3B,QAAM,WAAyC,OAAO,YAAY,CAAC;AACnE,QAAM,SAA0B,SAAS,UAAU,CAAC;AACpD,QAAM,SAAiB,SAAS,UAAU;AAC1C,QAAM,SAA6B,SAAS;AAC5C,QAAM,OAA2B,SAAS,QAAQ;AAClD,QAAM,SAAS,SAAS,UAAU;AAGlC,QAAM,SAAkB,CAAC;AACzB,aAAW,QAAQ,YAAY;AAC7B,UAAM,QACJ,SAAS,IAAI,MAAM,SAAS,SAAS,QAAQ,cAAc,IAAI;AACjE,QAAI,UAAU,MAAO,QAAO,KAAK,EAAE,MAAM,MAAM,CAAC;AAAA,EAClD;AAEA,MAAI,CAAC;AACH,WAAO;AAAA,MACL;AAAA,IACF;AAEF,QAAM,cAAc,oBAAI,IAAe;AAEvC,SAAO;AAAA,IACL,MAAM;AAAA,IACN;AAAA,IAIA,MAAM,KAAK,OAAOA,UAAS;AACzB,YAAM,EAAE,QAAQ,UAAU,IAAIA;AAE9B,YAAM,SAAS,kBAAkB,QAAQ,KAAK;AAC9C,YAAM,UAAU,OAAO,UACrB,OAAQ,MAAM,gBAAgB,QAAQ,OAAO,EAAE,UAAU,CAAC,KAAM,EAAE;AAEpE,YAAM,cAAc,MAAM,QAAQ;AAAA,QAChC,OAAO,IAAI,OAAO,EAAE,MAAM,MAAM,MAAM;AACpC,gBAAM,WAAW,MAAM,QAAQ,KAAK;AACpC,gBAAM,UAAU,WAAW,YAAY,IAAI,EAAE,QAAQ,IAAI;AAEzD,cAAI,CAAC,WAAW,CAAC,YAAY,IAAI,IAAI,GAAG;AACtC,wBAAY,IAAI,IAAI;AACpB,mBAAO;AAAA,cACL,sBAAsB,IAAI,mGAAmG,IAAI;AAAA,YACnI;AAAA,UACF;AAEA,iBAAO;AAAA,QACT,CAAC;AAAA,MACH;AAEA,YAAM,cAAc,MAAM,QAAQ,IAAI,OAAO,IAAI,OAAO,CAAC;AAGzD,YAAM,QAAQ;AAAA,QACZ,eAAe,MAAM;AAAA,QACrB,GAAG;AAAA,QACH,GAAG;AAAA,MACL,EAAE,KAAK,GAAQ;AAEf,YAAM,OAAO,MAAM;AAAA,QACjB;AAAA,QACA;AAAA,QACA,OAAO,EAAE,KAAK,KAAK,IAAI,CAAC;AAAA,MAC1B;AACA,aAAO,EAAE,OAAO,UAAU,OAAO,QAAQ,IAAI,EAAE;AAAA,IACjD;AAAA,EACF;AACF;","names":["context"]}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$meta": {
3
3
  "package": "@walkeros/server-transformer-fingerprint",
4
- "version": "4.6.0",
4
+ "version": "4.7.0-next-1790187973605",
5
5
  "type": "transformer",
6
6
  "platform": [
7
7
  "server"
@@ -14,7 +14,129 @@
14
14
  "$schema": "http://json-schema.org/draft-07/schema#",
15
15
  "type": "object",
16
16
  "properties": {
17
+ "salt": {
18
+ "description": "Secret key for the hash (HMAC), e.g. \"$env.FINGERPRINT_SALT\". Without it the hash can be reversed to the IP; a warning is logged.",
19
+ "type": "string"
20
+ },
21
+ "rotate": {
22
+ "description": "Rotation window in UTC: the same visitor gets a new hash each window. Default: \"daily\"",
23
+ "type": "string",
24
+ "enum": [
25
+ "daily",
26
+ "hourly",
27
+ "none"
28
+ ]
29
+ },
30
+ "ip": {
31
+ "description": "Client IP, anonymized to /24 (IPv4) or /48 (IPv6) before hashing. Default: \"ingest.ip\" when fields is not set",
32
+ "anyOf": [
33
+ {
34
+ "anyOf": [
35
+ {
36
+ "type": "string",
37
+ "description": "Dot-notation path: \"ingest.ip\", \"event.data.userId\""
38
+ },
39
+ {
40
+ "type": "object",
41
+ "properties": {
42
+ "key": {
43
+ "description": "Source property path",
44
+ "type": "string"
45
+ },
46
+ "value": {
47
+ "description": "Static value or fallback"
48
+ },
49
+ "fn": {
50
+ "description": "$code: function receiving { event, ingest } that returns the value. Runs only when no key is set.",
51
+ "type": "string"
52
+ }
53
+ },
54
+ "additionalProperties": false,
55
+ "description": "Mapping value config for computed fields"
56
+ }
57
+ ]
58
+ },
59
+ {
60
+ "type": "boolean",
61
+ "const": false,
62
+ "description": "Switch this input off"
63
+ }
64
+ ]
65
+ },
66
+ "userAgent": {
67
+ "description": "User agent, reduced to browser, major version and OS before hashing. Default: \"ingest.userAgent\" when fields is not set",
68
+ "anyOf": [
69
+ {
70
+ "anyOf": [
71
+ {
72
+ "type": "string",
73
+ "description": "Dot-notation path: \"ingest.ip\", \"event.data.userId\""
74
+ },
75
+ {
76
+ "type": "object",
77
+ "properties": {
78
+ "key": {
79
+ "description": "Source property path",
80
+ "type": "string"
81
+ },
82
+ "value": {
83
+ "description": "Static value or fallback"
84
+ },
85
+ "fn": {
86
+ "description": "$code: function receiving { event, ingest } that returns the value. Runs only when no key is set.",
87
+ "type": "string"
88
+ }
89
+ },
90
+ "additionalProperties": false,
91
+ "description": "Mapping value config for computed fields"
92
+ }
93
+ ]
94
+ },
95
+ {
96
+ "type": "boolean",
97
+ "const": false,
98
+ "description": "Switch this input off"
99
+ }
100
+ ]
101
+ },
102
+ "site": {
103
+ "description": "Site the visitor is on; a URL is reduced to its hostname. Default: \"event.source.url\" when fields is not set",
104
+ "anyOf": [
105
+ {
106
+ "anyOf": [
107
+ {
108
+ "type": "string",
109
+ "description": "Dot-notation path: \"ingest.ip\", \"event.data.userId\""
110
+ },
111
+ {
112
+ "type": "object",
113
+ "properties": {
114
+ "key": {
115
+ "description": "Source property path",
116
+ "type": "string"
117
+ },
118
+ "value": {
119
+ "description": "Static value or fallback"
120
+ },
121
+ "fn": {
122
+ "description": "$code: function receiving { event, ingest } that returns the value. Runs only when no key is set.",
123
+ "type": "string"
124
+ }
125
+ },
126
+ "additionalProperties": false,
127
+ "description": "Mapping value config for computed fields"
128
+ }
129
+ ]
130
+ },
131
+ {
132
+ "type": "boolean",
133
+ "const": false,
134
+ "description": "Switch this input off"
135
+ }
136
+ ]
137
+ },
17
138
  "fields": {
139
+ "description": "Extra fields to include in hash (order matters). Each resolved via getMappingValue with source { event, ingest }. When set, named inputs are only used if set explicitly.",
18
140
  "type": "array",
19
141
  "items": {
20
142
  "anyOf": [
@@ -33,7 +155,7 @@
33
155
  "description": "Static value or fallback"
34
156
  },
35
157
  "fn": {
36
- "description": "$code: function for value transformation",
158
+ "description": "$code: function receiving { event, ingest } that returns the value. Runs only when no key is set.",
37
159
  "type": "string"
38
160
  }
39
161
  },
@@ -41,8 +163,7 @@
41
163
  "description": "Mapping value config for computed fields"
42
164
  }
43
165
  ]
44
- },
45
- "description": "Fields to include in hash (order matters). Each resolved via getMappingValue with source { event, ingest }."
166
+ }
46
167
  },
47
168
  "output": {
48
169
  "description": "Dot-notation path where hash is stored on the event. Default: \"user.hash\"",
@@ -55,18 +176,15 @@
55
176
  "maximum": 9007199254740991
56
177
  }
57
178
  },
58
- "required": [
59
- "fields"
60
- ],
61
179
  "additionalProperties": false,
62
- "description": "Fingerprint transformer: generates deterministic user hashes from event fields"
180
+ "description": "Fingerprint transformer: a cookieless, privacy-friendly visitor hash"
63
181
  }
64
182
  },
65
183
  "examples": {
66
184
  "step": {
67
185
  "ipAnonymization": {
68
186
  "title": "IP anonymization",
69
- "description": "Privacy-preserving fingerprint using key+fn pattern: fn truncates IP to /24 subnet before hashing, so 10.0.42.* users share a hash. Config: fields: [{ key: \"ingest.ip\", fn: ip => ip.replace(/\\.\\d+$/, \".0\") }, \"ingest.userAgent\"]",
187
+ "description": "The ip input anonymizes the IP before hashing (IPv4 /24, IPv6 /48), so visitors from the same 10.0.42.* subnet with the same browser, major version and OS share a hash within a day. No custom fn needed. Config: { salt: \"$env.FINGERPRINT_SALT\", length: 16 }",
70
188
  "in": {
71
189
  "name": "page view",
72
190
  "data": {
@@ -114,7 +232,7 @@
114
232
  },
115
233
  "missingFields": {
116
234
  "public": false,
117
- "description": "Graceful handling when ingest is missing - fields resolve to empty strings, hash is still generated.",
235
+ "description": "Graceful handling when ingest is missing: each empty input logs one warning, and the hash is still generated.",
118
236
  "in": {
119
237
  "name": "session start",
120
238
  "data": {
@@ -158,7 +276,7 @@
158
276
  },
159
277
  "serverFingerprint": {
160
278
  "title": "Server fingerprint",
161
- "description": "Standard server fingerprint using ingest.ip and ingest.userAgent. Requires source config.ingest.",
279
+ "description": "Default server fingerprint: the anonymized ingest.ip, the reduced ingest.userAgent and the site, keyed with settings.salt and rotated daily. Config: { salt: \"$env.FINGERPRINT_SALT\", length: 16 }. Requires source config.ingest. The hash depends on the salt and the day.",
162
280
  "in": {
163
281
  "name": "page view",
164
282
  "data": {
@@ -208,16 +326,22 @@
208
326
  },
209
327
  "hints": {
210
328
  "ingest-prerequisite": {
211
- "text": "Fields starting with ingest.* require the server source to have config.ingest configured. config.ingest MUST use the map operator with direct request field paths (no req. prefix); a bare object like { ip: \"req.ip\" } is silently inert and leaves ingest empty. Without populated ingest, all ingest.* fields resolve to empty strings, the hash is still generated but not unique. Always pair this transformer with a source that extracts request metadata.",
329
+ "text": "The default inputs ip and userAgent read ingest.ip and ingest.userAgent, so the server source must have config.ingest configured. config.ingest MUST use the map operator with direct request field paths (no req. prefix); a bare object like { ip: \"req.ip\" } is silently inert and leaves ingest empty. An input that resolves empty logs one warning (\"Fingerprint input ... resolved empty\") and the hash is still generated, but less distinct. Behind a load balancer the request ip is the balancer; read the client from headers.x-forwarded-for instead.",
212
330
  "code": [
213
331
  {
214
332
  "lang": "json",
215
- "code": "{\n \"sources\": {\n \"express\": {\n \"package\": \"@walkeros/server-source-express\",\n \"config\": {\n \"settings\": {\n \"port\": 8080\n },\n \"ingest\": {\n \"map\": {\n \"ip\": {\n \"key\": \"ip\"\n },\n \"userAgent\": {\n \"key\": \"headers.user-agent\"\n },\n \"origin\": {\n \"key\": \"headers.origin\"\n }\n }\n }\n }\n }\n },\n \"transformers\": {\n \"fingerprint\": {\n \"package\": \"@walkeros/server-transformer-fingerprint\",\n \"config\": {\n \"settings\": {\n \"fields\": [\n \"ingest.ip\",\n \"ingest.userAgent\"\n ],\n \"output\": \"user.hash\",\n \"length\": 16\n }\n }\n }\n }\n}"
333
+ "code": "{\n \"sources\": {\n \"express\": {\n \"package\": \"@walkeros/server-source-express\",\n \"next\": \"fingerprint\",\n \"config\": {\n \"settings\": {\n \"port\": 8080\n },\n \"ingest\": {\n \"map\": {\n \"ip\": {\n \"key\": \"ip\"\n },\n \"userAgent\": {\n \"key\": \"headers.user-agent\"\n }\n }\n }\n }\n }\n },\n \"transformers\": {\n \"fingerprint\": {\n \"package\": \"@walkeros/server-transformer-fingerprint\",\n \"config\": {\n \"settings\": {\n \"salt\": \"$env.FINGERPRINT_SALT\"\n }\n }\n }\n }\n}"
216
334
  }
217
335
  ]
218
336
  },
219
- "fields-overview": {
220
- "text": "Fields resolve from { event, ingest } via walkerOS mapping. Common patterns: ingest.ip (client IP), ingest.userAgent (browser UA), event.data.* (any event property). For time-based rotation use fn fields: daily rotation with toISOString().slice(0,10), monthly with getDate(). Order matters — same fields in different order produce different hashes. Use { key, fn } objects to transform before hashing (e.g., IP anonymization via the ipAnonymization step example)."
337
+ "salt-and-rotation": {
338
+ "text": "The salt keys the hash (HMAC), so it cannot be recomputed or reversed to the IP without it. Pass it via $env so it stays out of the flow file, and use one salt per deployment. Without a salt the transformer runs but logs a warning at startup. Rotation is built in (daily by default, UTC windows): do not add a date field for it. For another window, set rotate: \"none\" and add the window as a field. Setting fields switches the default inputs off, so name them too: { rotate: \"none\", ip: \"ingest.ip\", userAgent: \"ingest.userAgent\", site: \"event.source.url\", fields: [{ fn: () => new Date().toISOString().slice(0, 7) }] } rotates monthly. rotate: \"none\" without such a field keeps one hash per visitor indefinitely, which is a privacy decision, not a default."
339
+ },
340
+ "site-sources": {
341
+ "text": "site defaults to the hostname of event.source.url, which the walkerOS browser source sets on every event. Events without it (GA4 hits decoded by transformer-ga4, server-side events) need site pointed elsewhere: an ingest path such as ingest.property (a path parameter) or ingest.origin (map it in config.ingest first, e.g. origin: { key: \"headers.origin\" }), or a static { value: \"example.com\" }. site keeps one visitor apart across sites when one collector with one salt serves several of them; a single-site deployment with its own salt is already separated by the salt."
342
+ },
343
+ "extra-fields": {
344
+ "text": "fields adds values to the hash as they resolve from { event, ingest }, in order. When fields is set, the named inputs ip, userAgent and site are only used if set explicitly, so an existing fields config keeps its input set. To transform a value before hashing, use fn alone: it receives { event, ingest } and returns the value. A fn next to a key never runs. The IP never needs a custom fn: the ip input anonymizes it (IPv4 /24, IPv6 /48)."
221
345
  }
222
346
  }
223
347
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@walkeros/server-transformer-fingerprint",
3
3
  "description": "Fingerprint transformer for walkerOS server - hash configurable fields for session continuity",
4
- "version": "4.6.0",
4
+ "version": "4.7.0-next-1790187973605",
5
5
  "license": "MIT",
6
6
  "main": "./dist/index.js",
7
7
  "module": "./dist/index.mjs",
@@ -27,8 +27,8 @@
27
27
  "update": "npx npm-check-updates -u && npm update"
28
28
  },
29
29
  "devDependencies": {
30
- "@walkeros/core": "4.6.0",
31
- "@walkeros/server-core": "4.6.0"
30
+ "@walkeros/core": "4.7.0-next-1790187973605",
31
+ "@walkeros/server-core": "4.7.0-next-1790187973605"
32
32
  },
33
33
  "repository": {
34
34
  "url": "git+https://github.com/elbwalker/walkerOS.git",
@@ -60,7 +60,7 @@
60
60
  }
61
61
  ],
62
62
  "dependencies": {
63
- "@walkeros/core": "4.6.0",
64
- "@walkeros/server-core": "4.6.0"
63
+ "@walkeros/core": "4.7.0-next-1790187973605",
64
+ "@walkeros/server-core": "4.7.0-next-1790187973605"
65
65
  }
66
66
  }