fix(n8n): address review feedback and pass the verification scanner
- Telemetry: replace client-side PostHog (which read process.env, banned in verified nodes) with first-party `source: "N8N"` attribution on API requests, mirroring OpenClaw. Removes the undisclosed-collection concern entirely. - Verification-scanner fixes: add credential icon, use NodeConnectionTypes.Main instead of the 'main' literal, wrap error paths in NodeApiError, stop shipping .d.ts / tsbuildinfo, and align the local eslint config with the scanner. - Add offline unit tests (jest): entity-id guard, JSON-parse errors, poll-timeout loop, source attribution, and Return All pagination. - Get Many: add a Return All option that pages through all memories. - Docs: README telemetry note and an AGENTS.md Key Directories row. Verified: build + tsc + eslint + 5 unit tests + live E2E against api.mem0.ai, and the packed tarball passes @n8n/scan-community-package with zero violations.
This commit is contained in:
@@ -8,10 +8,10 @@ import {
|
||||
INodeTypeDescription,
|
||||
JsonObject,
|
||||
NodeApiError,
|
||||
NodeConnectionTypes,
|
||||
NodeOperationError,
|
||||
sleep,
|
||||
} from 'n8n-workflow';
|
||||
import { captureEvent } from './telemetry';
|
||||
|
||||
// Poll settings for asynchronous (infer=true) memory addition.
|
||||
const POLL_INTERVAL_MS = 1500;
|
||||
@@ -31,8 +31,8 @@ export class Mem0 implements INodeType {
|
||||
},
|
||||
// Makes the node available to the AI Agent (Tools Agent) node.
|
||||
usableAsTool: true,
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
inputs: [NodeConnectionTypes.Main],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
credentials: [
|
||||
{
|
||||
name: 'mem0Api',
|
||||
@@ -263,13 +263,23 @@ export class Mem0 implements INodeType {
|
||||
displayOptions: { show: { resource: ['memory'], operation: ['getAll'] } },
|
||||
description: 'Restrict the listing to this user (required — the API needs an entity filter)',
|
||||
},
|
||||
{
|
||||
displayName: 'Return All',
|
||||
name: 'returnAll',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
displayOptions: { show: { resource: ['memory'], operation: ['getAll'] } },
|
||||
description: 'Whether to return all results or only up to a given limit',
|
||||
},
|
||||
{
|
||||
displayName: 'Page',
|
||||
name: 'page',
|
||||
type: 'number',
|
||||
typeOptions: { minValue: 1 },
|
||||
default: 1,
|
||||
displayOptions: { show: { resource: ['memory'], operation: ['getAll'] } },
|
||||
displayOptions: {
|
||||
show: { resource: ['memory'], operation: ['getAll'], returnAll: [false] },
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Page Size',
|
||||
@@ -315,13 +325,6 @@ export class Mem0 implements INodeType {
|
||||
const credentials = await this.getCredentials('mem0Api');
|
||||
const baseUrl = (credentials.baseUrl as string) || 'https://api.mem0.ai';
|
||||
|
||||
// Anonymous, fire-and-forget usage telemetry (opt out with MEM0_TELEMETRY=false).
|
||||
if (items.length) {
|
||||
captureEvent(`n8n.${this.getNodeParameter('operation', 0) as string}`, credentials.apiKey as string, {
|
||||
items: items.length,
|
||||
});
|
||||
}
|
||||
|
||||
const request = async (
|
||||
method: IHttpRequestMethods,
|
||||
url: string,
|
||||
@@ -333,7 +336,8 @@ export class Mem0 implements INodeType {
|
||||
url: `${baseUrl}${url}`,
|
||||
json: true,
|
||||
...(body ? { body } : {}),
|
||||
...(qs ? { qs } : {}),
|
||||
// First-party usage attribution: the backend reads `source` (same as OpenClaw).
|
||||
qs: { source: 'N8N', ...(qs ?? {}) },
|
||||
};
|
||||
return (await this.helpers.httpRequestWithAuthentication.call(
|
||||
this,
|
||||
@@ -440,12 +444,25 @@ export class Mem0 implements INodeType {
|
||||
responseData = Array.isArray(resp.results) ? (resp.results as IDataObject[]) : [];
|
||||
} else if (operation === 'getAll') {
|
||||
const userId = this.getNodeParameter('userId', i, '') as string;
|
||||
const page = this.getNodeParameter('page', i, 1) as number;
|
||||
const returnAll = this.getNodeParameter('returnAll', i, false) as boolean;
|
||||
const pageSize = this.getNodeParameter('pageSize', i, 50) as number;
|
||||
const body: IDataObject = {};
|
||||
if (userId) body.filters = { user_id: userId };
|
||||
const resp = await request('POST', '/v3/memories/', body, { page, page_size: pageSize });
|
||||
responseData = Array.isArray(resp.results) ? (resp.results as IDataObject[]) : [];
|
||||
if (returnAll) {
|
||||
// Page through until a short/empty page or no `next` (hard-capped for safety).
|
||||
const all: IDataObject[] = [];
|
||||
for (let page = 1; page <= 10000; page++) {
|
||||
const resp = await request('POST', '/v3/memories/', body, { page, page_size: pageSize });
|
||||
const results = Array.isArray(resp.results) ? (resp.results as IDataObject[]) : [];
|
||||
all.push(...results);
|
||||
if (results.length < pageSize || !resp.next) break;
|
||||
}
|
||||
responseData = all;
|
||||
} else {
|
||||
const page = this.getNodeParameter('page', i, 1) as number;
|
||||
const resp = await request('POST', '/v3/memories/', body, { page, page_size: pageSize });
|
||||
responseData = Array.isArray(resp.results) ? (resp.results as IDataObject[]) : [];
|
||||
}
|
||||
} else if (operation === 'get') {
|
||||
const memoryId = this.getNodeParameter('memoryId', i) as string;
|
||||
responseData = await request('GET', `/v1/memories/${memoryId}/`);
|
||||
@@ -484,8 +501,7 @@ export class Mem0 implements INodeType {
|
||||
returnData.push({ json: { error: (error as Error).message }, pairedItem: { item: i } });
|
||||
continue;
|
||||
}
|
||||
if (error instanceof NodeApiError || error instanceof NodeOperationError) throw error;
|
||||
throw new NodeApiError(this.getNode(), error as JsonObject);
|
||||
throw new NodeApiError(this.getNode(), error as JsonObject, { itemIndex: i });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
// Anonymous usage telemetry via PostHog, mirroring the Mem0 CLI / plugins.
|
||||
// Fire-and-forget: never awaited, never throws, so it can neither slow down
|
||||
// nor break a workflow. Opt out with MEM0_TELEMETRY=false.
|
||||
// Uses native fetch + crypto only — no runtime dependencies (verified-node rule).
|
||||
const POSTHOG_API_KEY = 'phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX';
|
||||
const POSTHOG_HOST = 'https://us.i.posthog.com/i/v0/e/';
|
||||
|
||||
export function captureEvent(
|
||||
event: string,
|
||||
apiKey: string | undefined,
|
||||
properties: Record<string, unknown> = {},
|
||||
): void {
|
||||
if (process.env.MEM0_TELEMETRY === 'false') return;
|
||||
try {
|
||||
// Hash the API key so events are attributable to one account without
|
||||
// ever transmitting the key itself.
|
||||
const distinctId = apiKey ? createHash('md5').update(apiKey).digest('hex') : 'n8n-anon';
|
||||
const payload = {
|
||||
api_key: POSTHOG_API_KEY,
|
||||
event,
|
||||
distinct_id: distinctId,
|
||||
properties: {
|
||||
source: 'N8N',
|
||||
$process_person_profile: false,
|
||||
...properties,
|
||||
},
|
||||
};
|
||||
void fetch(POSTHOG_HOST, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}).catch(() => {
|
||||
/* swallow — telemetry must never surface to the user */
|
||||
});
|
||||
} catch {
|
||||
/* swallow — telemetry must never break the workflow */
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user