@hostwebhook/node-sdk 0.2.0 → 0.4.0

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.
@@ -0,0 +1,73 @@
1
+ import { Connection, Types } from 'mongoose';
2
+ /**
3
+ * Reject a node wired to itself.
4
+ *
5
+ * A canvas drag from a node's own output dot to its own input dot produced a
6
+ * real, persisted edge: source and target were the same id. Nothing stopped it
7
+ * — the dashboard assumed the registry prevented invalid connections, and the
8
+ * registry answers a different question. `canReceiveFrom('transform',
9
+ * 'transform')` is TRUE, and correctly so: chaining two different Transform
10
+ * nodes is ordinary. What the type pair cannot express is "not this same
11
+ * instance", because ids are not types.
12
+ *
13
+ * The one legitimate self-edge is the Loop node's loop-back, and the registry
14
+ * already declares it as `special.hasLoopBack`. So the exception is read from
15
+ * there rather than hardcoded here — a future node that needs to re-enter
16
+ * itself declares the flag and both the canvas and this check follow.
17
+ */
18
+ export declare function assertNoSelfConnection(nodeType: string, nodeId: Types.ObjectId | string, nodes: Array<{
19
+ nodeType: string;
20
+ nodeId: any;
21
+ }>): void;
22
+ /**
23
+ * Rechaza una conexión entre tipos que no pueden conectarse.
24
+ *
25
+ * Hasta ahora la compatibilidad de tipos era cosa del lienzo, y estaba escrito
26
+ * aquí al lado: *"Type compatibility is the registry's job and is checked on
27
+ * the canvas"*. Eso valía mientras el lienzo fuera el único que escribe, y no
28
+ * lo es: `/agent-context` le explica a un agente, con ejemplos, que meta
29
+ * `{ nodeType, nodeId }` a mano en `inputNodes`/`outputNodes`, y cualquier
30
+ * cliente con la clave y el scope puede hacer lo mismo.
31
+ *
32
+ * Lo que se colaba por ahí no revienta: **desaparece**. `dispatchOutputNodes`
33
+ * cae en su rama por defecto, `getNodeHandler('trigger')` no devuelve nada y
34
+ * suelta un `logger.warn` —sin evento de telemetría, porque el `telemetry.emit`
35
+ * está en el `catch` y esto no lanza—. O sea una arista dibujada en el lienzo
36
+ * que no dispara nunca y que no aparece en ningún sitio donde mirarla.
37
+ *
38
+ * ⚠️ **La regla es `fromNodes`/`toNodes` de `NODE_UI`, y no las listas de
39
+ * `NODE_CONNECTIONS`.** La tentación es usar `acceptsInputFrom`/`canOutputTo`,
40
+ * que están ahí mismo, las declaran los 43 tipos y parecen más precisas. Están
41
+ * podridas: medidas contra producción el 2026-08-19 rechazaban **111 de 364**
42
+ * conexiones que funcionan hoy — `trigger → ai`, `ai → telegramAction`,
43
+ * `sheetsAction → filter`. `fromNodes`/`toNodes` rechazaba **0**, porque es el
44
+ * mismo dato que decide qué puntos pinta la tarjeta. Endurecer con eso no
45
+ * puede romper nada que el usuario haya podido crear desde el lienzo, que es
46
+ * justo la propiedad que se busca: la api no debe ser más estricta que la
47
+ * pantalla, sólo igual de estricta en los caminos que la pantalla no cubre.
48
+ *
49
+ * De un tipo que el registro no conoce no se opina. Un alias viejo se
50
+ * normaliza primero (`voiceCall` → `voiceAgent`); lo que ni así aparezca se
51
+ * deja pasar, porque un 400 sobre datos heredados con autoguardado deja la
52
+ * página sin poder guardar nunca más.
53
+ */
54
+ export declare function assertConnectableTypes(ownType: string, refs: {
55
+ inputNodes?: Array<{
56
+ nodeType: string;
57
+ }>;
58
+ outputNodes?: Array<{
59
+ nodeType: string;
60
+ }>;
61
+ }): void;
62
+ /**
63
+ * Validate that all target nodes belong to the same workspace as the source node.
64
+ * Throws BadRequestException if any cross-workspace connection is detected.
65
+ *
66
+ * This is the ONLY backend connection logic. The frontend owns connection
67
+ * creation/deletion — it updates both sides (source.outputNodes + target.inputNodes).
68
+ * The backend just saves what it receives and validates workspace boundaries.
69
+ */
70
+ export declare function validateConnectionWorkspaces(connection: Connection, sourceWorkspaceId: Types.ObjectId | string | null | undefined, nodes: Array<{
71
+ nodeType: string;
72
+ nodeId: any;
73
+ }>): Promise<void>;
@@ -0,0 +1,120 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.assertNoSelfConnection = assertNoSelfConnection;
4
+ exports.assertConnectableTypes = assertConnectableTypes;
5
+ exports.validateConnectionWorkspaces = validateConnectionWorkspaces;
6
+ const common_1 = require("@nestjs/common");
7
+ const mongoose_1 = require("mongoose");
8
+ const node_types_1 = require("@hostwebhook/node-types");
9
+ const node_dispatch_registry_1 = require("./node-dispatch.registry");
10
+ const output_node_dto_1 = require("./dto/output-node.dto");
11
+ /** Map nodeType → MongoDB collection name */
12
+ const TYPE_TO_COLLECTION = {};
13
+ for (const entry of node_dispatch_registry_1.NODE_DISPATCH_REGISTRY) {
14
+ TYPE_TO_COLLECTION[entry.type] = entry.collection;
15
+ }
16
+ /**
17
+ * Reject a node wired to itself.
18
+ *
19
+ * A canvas drag from a node's own output dot to its own input dot produced a
20
+ * real, persisted edge: source and target were the same id. Nothing stopped it
21
+ * — the dashboard assumed the registry prevented invalid connections, and the
22
+ * registry answers a different question. `canReceiveFrom('transform',
23
+ * 'transform')` is TRUE, and correctly so: chaining two different Transform
24
+ * nodes is ordinary. What the type pair cannot express is "not this same
25
+ * instance", because ids are not types.
26
+ *
27
+ * The one legitimate self-edge is the Loop node's loop-back, and the registry
28
+ * already declares it as `special.hasLoopBack`. So the exception is read from
29
+ * there rather than hardcoded here — a future node that needs to re-enter
30
+ * itself declares the flag and both the canvas and this check follow.
31
+ */
32
+ function assertNoSelfConnection(nodeType, nodeId, nodes) {
33
+ if (node_types_1.NODE_CONNECTIONS[nodeType]?.special?.hasLoopBack)
34
+ return;
35
+ const selfId = nodeId.toString();
36
+ for (const node of nodes) {
37
+ if (node.nodeType === nodeType && node.nodeId?.toString() === selfId) {
38
+ throw new common_1.BadRequestException(`A ${nodeType} node cannot connect to itself`);
39
+ }
40
+ }
41
+ }
42
+ /**
43
+ * Rechaza una conexión entre tipos que no pueden conectarse.
44
+ *
45
+ * Hasta ahora la compatibilidad de tipos era cosa del lienzo, y estaba escrito
46
+ * aquí al lado: *"Type compatibility is the registry's job and is checked on
47
+ * the canvas"*. Eso valía mientras el lienzo fuera el único que escribe, y no
48
+ * lo es: `/agent-context` le explica a un agente, con ejemplos, que meta
49
+ * `{ nodeType, nodeId }` a mano en `inputNodes`/`outputNodes`, y cualquier
50
+ * cliente con la clave y el scope puede hacer lo mismo.
51
+ *
52
+ * Lo que se colaba por ahí no revienta: **desaparece**. `dispatchOutputNodes`
53
+ * cae en su rama por defecto, `getNodeHandler('trigger')` no devuelve nada y
54
+ * suelta un `logger.warn` —sin evento de telemetría, porque el `telemetry.emit`
55
+ * está en el `catch` y esto no lanza—. O sea una arista dibujada en el lienzo
56
+ * que no dispara nunca y que no aparece en ningún sitio donde mirarla.
57
+ *
58
+ * ⚠️ **La regla es `fromNodes`/`toNodes` de `NODE_UI`, y no las listas de
59
+ * `NODE_CONNECTIONS`.** La tentación es usar `acceptsInputFrom`/`canOutputTo`,
60
+ * que están ahí mismo, las declaran los 43 tipos y parecen más precisas. Están
61
+ * podridas: medidas contra producción el 2026-08-19 rechazaban **111 de 364**
62
+ * conexiones que funcionan hoy — `trigger → ai`, `ai → telegramAction`,
63
+ * `sheetsAction → filter`. `fromNodes`/`toNodes` rechazaba **0**, porque es el
64
+ * mismo dato que decide qué puntos pinta la tarjeta. Endurecer con eso no
65
+ * puede romper nada que el usuario haya podido crear desde el lienzo, que es
66
+ * justo la propiedad que se busca: la api no debe ser más estricta que la
67
+ * pantalla, sólo igual de estricta en los caminos que la pantalla no cubre.
68
+ *
69
+ * De un tipo que el registro no conoce no se opina. Un alias viejo se
70
+ * normaliza primero (`voiceCall` → `voiceAgent`); lo que ni así aparezca se
71
+ * deja pasar, porque un 400 sobre datos heredados con autoguardado deja la
72
+ * página sin poder guardar nunca más.
73
+ */
74
+ function assertConnectableTypes(ownType, refs) {
75
+ // En `outputNodes` yo soy el origen y la referencia es el destino.
76
+ for (const ref of refs.outputNodes ?? [])
77
+ assertPar(ownType, (0, output_node_dto_1.normalizeNodeType)(ref.nodeType));
78
+ // En `inputNodes` es al revés.
79
+ for (const ref of refs.inputNodes ?? [])
80
+ assertPar((0, output_node_dto_1.normalizeNodeType)(ref.nodeType), ownType);
81
+ }
82
+ function assertPar(origen, destino) {
83
+ const uiOrigen = node_types_1.NODE_UI[origen];
84
+ const uiDestino = node_types_1.NODE_UI[destino];
85
+ if (!uiOrigen || !uiDestino)
86
+ return;
87
+ if (uiOrigen.toNodes !== true) {
88
+ throw new common_1.BadRequestException(`A ${origen} node has no output — nothing can start from it`);
89
+ }
90
+ if (uiDestino.fromNodes !== true) {
91
+ throw new common_1.BadRequestException(`A ${destino} node has no input — it can only start a flow`);
92
+ }
93
+ }
94
+ /**
95
+ * Validate that all target nodes belong to the same workspace as the source node.
96
+ * Throws BadRequestException if any cross-workspace connection is detected.
97
+ *
98
+ * This is the ONLY backend connection logic. The frontend owns connection
99
+ * creation/deletion — it updates both sides (source.outputNodes + target.inputNodes).
100
+ * The backend just saves what it receives and validates workspace boundaries.
101
+ */
102
+ async function validateConnectionWorkspaces(connection, sourceWorkspaceId, nodes) {
103
+ if (!sourceWorkspaceId)
104
+ return;
105
+ const srcWsStr = sourceWorkspaceId.toString();
106
+ for (const node of nodes) {
107
+ const col = TYPE_TO_COLLECTION[node.nodeType];
108
+ if (!col)
109
+ continue;
110
+ const doc = await connection
111
+ .collection(col)
112
+ .findOne({ _id: new mongoose_1.Types.ObjectId(node.nodeId) }, { projection: { workspaceId: 1 } });
113
+ if (!doc)
114
+ continue;
115
+ const targetWs = doc.workspaceId?.toString() ?? null;
116
+ if (targetWs && targetWs !== srcWsStr) {
117
+ throw new common_1.BadRequestException(`Cannot connect nodes across workspaces (source workspace: ${srcWsStr}, target workspace: ${targetWs})`);
118
+ }
119
+ }
120
+ }
@@ -0,0 +1,6 @@
1
+ import { Connection } from 'mongoose';
2
+ /**
3
+ * Ensures no other node in the workspace already uses this name.
4
+ * Searches across ALL node collections (cross-type uniqueness).
5
+ */
6
+ export declare function assertUniqueNodeName(connection: Connection, workspaceId: any, name: string, excludeId?: string): Promise<void>;
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.assertUniqueNodeName = assertUniqueNodeName;
4
+ const common_1 = require("@nestjs/common");
5
+ const mongoose_1 = require("mongoose");
6
+ const node_types_1 = require("@hostwebhook/node-types");
7
+ const SKIP_TYPES = new Set(['stickyNote']);
8
+ const MAX_NODE_NAME_LENGTH = 50;
9
+ /**
10
+ * Ensures no other node in the workspace already uses this name.
11
+ * Searches across ALL node collections (cross-type uniqueness).
12
+ */
13
+ async function assertUniqueNodeName(connection, workspaceId, name, excludeId) {
14
+ if (!name)
15
+ return;
16
+ if (name.length > MAX_NODE_NAME_LENGTH) {
17
+ throw new common_1.BadRequestException(`Node name must be ${MAX_NODE_NAME_LENGTH} characters or less (got ${name.length}).`);
18
+ }
19
+ const wsId = workspaceId ? new mongoose_1.Types.ObjectId(workspaceId) : null;
20
+ for (const [type, config] of Object.entries(node_types_1.NODE_DISPATCH)) {
21
+ if (SKIP_TYPES.has(type))
22
+ continue;
23
+ const col = connection.collection(config.collection);
24
+ const filter = { name, workspaceId: wsId };
25
+ if (excludeId)
26
+ filter._id = { $ne: new mongoose_1.Types.ObjectId(excludeId) };
27
+ const existing = await col.findOne(filter, { projection: { _id: 1 } });
28
+ if (existing) {
29
+ throw new common_1.ConflictException(`A node named "${name}" already exists in this workspace. Please choose a different name.`);
30
+ }
31
+ }
32
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hostwebhook/node-sdk",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "El SDK de un nodo de HostWebhook: ciclo de vida, ejecución con iteración, reintentos, filtros y validación de esquema — lo que comparten la api y el futuro servicio de nodos",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -8,7 +8,7 @@
8
8
  "dist"
9
9
  ],
10
10
  "scripts": {
11
- "build": "npm --prefix ../node-types run build && npm --prefix ../template-engine run build && tsc",
11
+ "build": "npm --prefix ../platform-contracts run build && npm --prefix ../node-types run build && npm --prefix ../template-engine run build && tsc",
12
12
  "prepublishOnly": "npm run build",
13
13
  "test": "vitest run"
14
14
  },
@@ -19,6 +19,7 @@
19
19
  ],
20
20
  "license": "MIT",
21
21
  "peerDependencies": {
22
+ "@hostwebhook/platform-contracts": ">=0.2.0",
22
23
  "@hostwebhook/node-types": ">=1.67.0",
23
24
  "@hostwebhook/template-engine": ">=2.0.0",
24
25
  "@nestjs/common": ">=11.0.0",
@@ -32,6 +33,7 @@
32
33
  },
33
34
  "devDependencies": {
34
35
  "typescript": "^5.0.0",
35
- "vitest": "^3.0.0"
36
+ "vitest": "^3.0.0",
37
+ "@hostwebhook/platform-contracts": "^0.2.0"
36
38
  }
37
39
  }