@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,51 @@
1
+ import { Connection, Types } from 'mongoose';
2
+ /**
3
+ * Corta si `userId` no está en la lista del workspace al que pertenece el
4
+ * recurso.
5
+ *
6
+ * Las tres salidas por arriba son deliberadas y copian, una a una, las que ya
7
+ * tomaba el guardia — el objetivo es aplicar la MISMA regla en más sitios, no
8
+ * una regla nueva:
9
+ *
10
+ * - sin workspace, no hay lista que aplicar (un nodo suelto);
11
+ * - sin `userId` no hay contra qué comparar. Ojo con lo que ESO no es: una
12
+ * API key SÍ trae identidad. Las dos rutas que la validan
13
+ * —`api-key.strategy.ts` y `api-key-validator.service.ts`— cargan el
14
+ * usuario dueño de la llave con `userModel.findById(apiKey.userId)`, así
15
+ * que `req.user._id` viene puesto y la lista se aplica igual que en una
16
+ * sesión. Esta salida cubre a un llamante interno sin usuario resuelto,
17
+ * no a las API keys;
18
+ * - lista vacía sigue significando «toda la organización».
19
+ *
20
+ * Y una que el guardia no tenía: si el workspace ya no existe, se deja pasar.
21
+ * El recurso se buscó con el `organizationId` de quien llama, así que es suyo;
22
+ * negarle su propio nodo porque el workspace se borró debajo sería romperlo
23
+ * por un dato que falta, no cerrar un agujero.
24
+ */
25
+ export declare function asegurarAccesoAlWorkspace(connection: Connection, workspaceId: string | Types.ObjectId | null | undefined, userId: string | undefined): Promise<void>;
26
+ /**
27
+ * Corta si el workspace no es de esta organización.
28
+ *
29
+ * Al crear un nodo, `workspaceId` llega del cuerpo y se escribía tal cual. El
30
+ * guardia tampoco lo frenaba: un id de OTRA organización no aparece en su
31
+ * consulta —que filtra por organización— y su regla para «no encontrado» es
32
+ * dejar contestar al manejador, para que un id equivocado siga dando 404 en
33
+ * vez de delatar su existencia con un 403. Sólo que el manejador tampoco
34
+ * miraba, así que el nodo acababa guardado apuntando al workspace de otro.
35
+ *
36
+ * Aquí «no encontrado» sí puede negar sin delatar nada: quien crea el nodo
37
+ * escribió ese id, y de un id que no es de su organización no aprende nada
38
+ * nuevo al recibir un 403.
39
+ */
40
+ export declare function asegurarWorkspaceDeLaOrganizacion(connection: Connection, workspaceId: string | Types.ObjectId | null | undefined, orgId: string): Promise<void>;
41
+ /**
42
+ * Los workspaces de la organización que este usuario puede ver.
43
+ *
44
+ * Devuelve `null` cuando NO hay nada que recortar —ningún workspace de la
45
+ * organización tiene lista, o no hay usuario resuelto—, y ese `null` importa: significa «no filtres», así que la consulta
46
+ * y su clave de caché salen exactamente como salían. Sin eso, una lectura
47
+ * filtrada por usuario compartiría entrada de caché con una sin filtrar y
48
+ * acabaría sirviéndole a alguien la vista de otro, que es un agujero peor que
49
+ * el que se está cerrando.
50
+ */
51
+ export declare function workspacesVisibles(connection: Connection, orgId: string, userId: string | undefined): Promise<Types.ObjectId[] | null>;
@@ -0,0 +1,99 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.asegurarAccesoAlWorkspace = asegurarAccesoAlWorkspace;
4
+ exports.asegurarWorkspaceDeLaOrganizacion = asegurarWorkspaceDeLaOrganizacion;
5
+ exports.workspacesVisibles = workspacesVisibles;
6
+ const common_1 = require("@nestjs/common");
7
+ const mongoose_1 = require("mongoose");
8
+ async function leerWorkspace(connection, workspaceId) {
9
+ if (!mongoose_1.Types.ObjectId.isValid(workspaceId))
10
+ return null;
11
+ return connection
12
+ .collection('workspaces')
13
+ .findOne({ _id: new mongoose_1.Types.ObjectId(workspaceId) }, { projection: { allowedUserIds: 1, organizationId: 1, name: 1 } });
14
+ }
15
+ /**
16
+ * Corta si `userId` no está en la lista del workspace al que pertenece el
17
+ * recurso.
18
+ *
19
+ * Las tres salidas por arriba son deliberadas y copian, una a una, las que ya
20
+ * tomaba el guardia — el objetivo es aplicar la MISMA regla en más sitios, no
21
+ * una regla nueva:
22
+ *
23
+ * - sin workspace, no hay lista que aplicar (un nodo suelto);
24
+ * - sin `userId` no hay contra qué comparar. Ojo con lo que ESO no es: una
25
+ * API key SÍ trae identidad. Las dos rutas que la validan
26
+ * —`api-key.strategy.ts` y `api-key-validator.service.ts`— cargan el
27
+ * usuario dueño de la llave con `userModel.findById(apiKey.userId)`, así
28
+ * que `req.user._id` viene puesto y la lista se aplica igual que en una
29
+ * sesión. Esta salida cubre a un llamante interno sin usuario resuelto,
30
+ * no a las API keys;
31
+ * - lista vacía sigue significando «toda la organización».
32
+ *
33
+ * Y una que el guardia no tenía: si el workspace ya no existe, se deja pasar.
34
+ * El recurso se buscó con el `organizationId` de quien llama, así que es suyo;
35
+ * negarle su propio nodo porque el workspace se borró debajo sería romperlo
36
+ * por un dato que falta, no cerrar un agujero.
37
+ */
38
+ async function asegurarAccesoAlWorkspace(connection, workspaceId, userId) {
39
+ if (!workspaceId || !userId)
40
+ return;
41
+ const workspace = await leerWorkspace(connection, workspaceId);
42
+ if (!workspace)
43
+ return;
44
+ const permitidos = workspace.allowedUserIds ?? [];
45
+ if (permitidos.length === 0)
46
+ return;
47
+ if (permitidos.some((id) => id.toString() === userId))
48
+ return;
49
+ throw new common_1.ForbiddenException('You do not have access to this workspace.');
50
+ }
51
+ /**
52
+ * Corta si el workspace no es de esta organización.
53
+ *
54
+ * Al crear un nodo, `workspaceId` llega del cuerpo y se escribía tal cual. El
55
+ * guardia tampoco lo frenaba: un id de OTRA organización no aparece en su
56
+ * consulta —que filtra por organización— y su regla para «no encontrado» es
57
+ * dejar contestar al manejador, para que un id equivocado siga dando 404 en
58
+ * vez de delatar su existencia con un 403. Sólo que el manejador tampoco
59
+ * miraba, así que el nodo acababa guardado apuntando al workspace de otro.
60
+ *
61
+ * Aquí «no encontrado» sí puede negar sin delatar nada: quien crea el nodo
62
+ * escribió ese id, y de un id que no es de su organización no aprende nada
63
+ * nuevo al recibir un 403.
64
+ */
65
+ async function asegurarWorkspaceDeLaOrganizacion(connection, workspaceId, orgId) {
66
+ if (!workspaceId)
67
+ return;
68
+ const workspace = await leerWorkspace(connection, workspaceId);
69
+ if (workspace && workspace.organizationId?.toString() === orgId)
70
+ return;
71
+ throw new common_1.ForbiddenException('That workspace does not belong to your organization.');
72
+ }
73
+ /**
74
+ * Los workspaces de la organización que este usuario puede ver.
75
+ *
76
+ * Devuelve `null` cuando NO hay nada que recortar —ningún workspace de la
77
+ * organización tiene lista, o no hay usuario resuelto—, y ese `null` importa: significa «no filtres», así que la consulta
78
+ * y su clave de caché salen exactamente como salían. Sin eso, una lectura
79
+ * filtrada por usuario compartiría entrada de caché con una sin filtrar y
80
+ * acabaría sirviéndole a alguien la vista de otro, que es un agujero peor que
81
+ * el que se está cerrando.
82
+ */
83
+ async function workspacesVisibles(connection, orgId, userId) {
84
+ if (!userId)
85
+ return null;
86
+ const todos = await connection
87
+ .collection('workspaces')
88
+ .find({ organizationId: new mongoose_1.Types.ObjectId(orgId) }, { projection: { allowedUserIds: 1 } })
89
+ .toArray();
90
+ const restringidos = todos.filter((w) => (w.allowedUserIds ?? []).length > 0);
91
+ if (restringidos.length === 0)
92
+ return null;
93
+ const fuera = restringidos.filter((w) => !(w.allowedUserIds ?? []).some((id) => id.toString() === userId));
94
+ if (fuera.length === 0)
95
+ return null;
96
+ return todos
97
+ .filter((w) => !fuera.some((f) => f._id.toString() === w._id.toString()))
98
+ .map((w) => w._id);
99
+ }
@@ -0,0 +1,120 @@
1
+ import { Model, Connection, Types } from 'mongoose';
2
+ /**
3
+ * Base service for node entities (HTTP Actions, Transform Nodes, Filter Nodes, Sheets Actions, etc.).
4
+ * Provides standard CRUD, saveLastPayload, findActiveByInput, and connection validation.
5
+ *
6
+ * Subclasses must define:
7
+ * - `entityName`: human-readable name for error messages (e.g. 'HTTP action')
8
+ * - `cascadeNodeType`: type key for cascadeCleanupNodeReferences (e.g. 'httpAction')
9
+ */
10
+ export declare abstract class BaseNodeService<TDoc extends {
11
+ id?: string;
12
+ _id?: any;
13
+ workspaceId?: any;
14
+ }> {
15
+ protected readonly model: Model<TDoc>;
16
+ protected readonly connection: Connection;
17
+ protected abstract readonly entityName: string;
18
+ protected abstract readonly cascadeNodeType: string;
19
+ constructor(model: Model<TDoc>, connection: Connection);
20
+ findAll(orgId: string, workspaceId?: string, visibles?: Types.ObjectId[] | null): Promise<TDoc[]>;
21
+ findOne(id: string, orgId: string): Promise<TDoc>;
22
+ /**
23
+ * Devuelve el `organizationId` del nodo, que ya se busca aquí dentro.
24
+ *
25
+ * Lo necesita quien quiera avisar por socket de que el payload cambió: sin
26
+ * esto tendría que repetir la misma consulta justo después. Los llamantes
27
+ * que no lo usen no se enteran — antes devolvía `void`.
28
+ */
29
+ saveLastPayload(id: string, payload: any,
30
+ /**
31
+ * De dónde sale este payload.
32
+ *
33
+ * `example: true` sólo cuando el usuario promueve una PLANTILLA desde el
34
+ * Test push: datos inventados, con la forma exacta que el nodo va a
35
+ * despachar, para poder montar el flujo antes del primer evento real.
36
+ *
37
+ * ⚠️ Va FUERA del payload a propósito. Meter la marca dentro cambiaría la
38
+ * forma —y el sprint entero fue sobre que la plantilla sea la forma
39
+ * EXACTA—, así que quien copiara `{{payload.x}}` desde ahí acabaría con
40
+ * una ruta que no existe en la entrega de verdad.
41
+ */
42
+ meta?: {
43
+ example?: boolean;
44
+ }): Promise<{
45
+ organizationId: string;
46
+ } | undefined>;
47
+ remove(id: string, orgId: string): Promise<void>;
48
+ /** Override for custom cascade cleanup (e.g. router rules). Default is no-op. */
49
+ protected onCascadeDelete(_id: string): Promise<void>;
50
+ /** Sugar for the one lookup event-pipeline does often enough to name. */
51
+ findActiveByInputWebhook(webhookId: string): Promise<TDoc[]>;
52
+ /** Find active nodes where the given node is an input source */
53
+ findActiveByInput(nodeType: string, nodeId: string): Promise<TDoc[]>;
54
+ /**
55
+ * Standard create: assertUniqueNodeName → model.create → normalizeAndValidateConnections.
56
+ * Pass node-specific fields via `fields` — userId, orgId, workspaceId, inputNodes,
57
+ * outputNodes, isActive, and name are handled automatically.
58
+ */
59
+ protected createNode(dto: {
60
+ name?: string;
61
+ isActive?: boolean;
62
+ inputNodes?: any[];
63
+ outputNodes?: any[];
64
+ workspaceId?: string;
65
+ [key: string]: any;
66
+ }, userId: string, orgId: string, extraFields?: Record<string, any>): Promise<TDoc>;
67
+ /**
68
+ * Normalize inputNodes/outputNodes and validate the structural rules.
69
+ *
70
+ * Tres reglas. Dos son sobre la forma del grafo y necesitan ids —no se puede
71
+ * cruzar de workspace, y un nodo no se conecta a sí mismo—, y por eso viven
72
+ * aquí y no en el registro.
73
+ *
74
+ * La tercera sí es de tipos. Aquí ponía que la compatibilidad «es cosa del
75
+ * registro y se comprueba en el lienzo», y eso sólo vale si el lienzo es el
76
+ * único que escribe. No lo es: `/agent-context` le explica a un agente que
77
+ * meta las referencias a mano, y cualquier cliente con la clave puede. Lo
78
+ * que entra por ahí no falla, desaparece — ver `assertConnectableTypes`.
79
+ */
80
+ protected normalizeAndValidateConnections(dto: {
81
+ inputNodes?: any[];
82
+ outputNodes?: any[];
83
+ }, workspaceId: any, selfId?: any): Promise<void>;
84
+ /** Build update payload with normalized nodes */
85
+ protected buildUpdatePayload(dto: Record<string, any>): Record<string, any>;
86
+ /**
87
+ * Walk the entity's schema and convert any field declared as
88
+ * `Types.ObjectId` (or `[Types.ObjectId]`) from a string back to a
89
+ * proper ObjectId before the value enters a `findOneAndUpdate` $set.
90
+ *
91
+ * **Why this exists:** Mongoose does NOT auto-cast nested $set paths to
92
+ * the schema's declared types — the cast only fires for full-document
93
+ * `save()` and `Document.set()`. So a dashboard PATCH that lands a
94
+ * `credentialId: "69fe70…"` string in the dto would persist a String
95
+ * even though the schema says `Types.ObjectId`. That's invisible to
96
+ * `_id`-keyed lookups (Mongoose casts query filters), but breaks any
97
+ * downstream query using `field: { $in: [ObjectId(…)] }` because
98
+ * MongoDB is strict-typed: String !== ObjectId.
99
+ *
100
+ * The Slack-trigger `processSlackCommand` was the canary: it routes
101
+ * inbound pushes by `team_id → credentials → triggers $in [ObjectId]`
102
+ * and silently dropped every `/joke` invocation as `no-trigger-opted-in`
103
+ * even though the toggle was correctly true on the entity. Fix landed
104
+ * 2026-05-09 — see `feedback_basenode_objectid_cast.md` for the full
105
+ * forensics. Lifting the cast here means every node service inherits
106
+ * the protection without repeating per-subclass logic.
107
+ *
108
+ * Schema-driven so adding a new ObjectId field on any entity
109
+ * (including Array<ObjectId> like `[{type: ObjectId, ref: 'Foo'}]`)
110
+ * gets covered automatically — no maintenance needed.
111
+ *
112
+ * Skips `_id` (Mongoose handles it explicitly in query casting),
113
+ * skips `null`/`undefined` (preserves "clear the relationship"
114
+ * semantics), skips non-hex strings (throwing here would mask
115
+ * real validation errors).
116
+ */
117
+ private castSchemaObjectIdFields;
118
+ /** Standard update with node normalization and workspace validation */
119
+ update(id: string, dto: Record<string, any>, orgId: string): Promise<TDoc>;
120
+ }